From f5d40d6f305c8d5a40b005018a7ea39fc32971d7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 18 Sep 2026 18:59:16 +0530 Subject: [PATCH] fix(ai): keep valid Responses provider options when a sibling is malformed (#49769) --- packages/ai/src/protocols/alibaba-chat.ts | 4 +- .../ai/src/protocols/alibaba-responses.ts | 4 +- .../protocols/utils/open-responses-options.ts | 37 ++++++++++--------- packages/ai/test/provider/openai-chat.test.ts | 15 ++++++++ .../ai/test/provider/openai-responses.test.ts | 24 ++++++++++++ 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/packages/ai/src/protocols/alibaba-chat.ts b/packages/ai/src/protocols/alibaba-chat.ts index 919828f097e..d0457269e7d 100644 --- a/packages/ai/src/protocols/alibaba-chat.ts +++ b/packages/ai/src/protocols/alibaba-chat.ts @@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js" export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort const Options = Schema.Struct({ - reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort, + reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort), enableThinking: Schema.optional(Schema.Boolean), thinkingBudget: Schema.optional(Schema.Int), preserveThinking: Schema.optional(Schema.Boolean), @@ -19,7 +19,7 @@ const Options = Schema.Struct({ }), ), toolStream: Schema.optional(Schema.Boolean), - parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls, + parallelToolCalls: Schema.optional(Schema.Boolean), repetitionPenalty: Schema.optional(Schema.Number), responseFormat: Schema.optional( Schema.Struct({ diff --git a/packages/ai/src/protocols/alibaba-responses.ts b/packages/ai/src/protocols/alibaba-responses.ts index 38cb07beeb3..24c1748c374 100644 --- a/packages/ai/src/protocols/alibaba-responses.ts +++ b/packages/ai/src/protocols/alibaba-responses.ts @@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js" import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js" const Options = Schema.Struct({ - reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort, + reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort), enableThinking: Schema.optional(Schema.Boolean), - store: OpenResponsesOptions.Options.fields.store, + store: Schema.optional(Schema.Boolean), previousResponseId: Schema.optional(Schema.String), conversation: Schema.optional(Schema.String), }) diff --git a/packages/ai/src/protocols/utils/open-responses-options.ts b/packages/ai/src/protocols/utils/open-responses-options.ts index e3ac2886efa..fbd0f0dc5cf 100644 --- a/packages/ai/src/protocols/utils/open-responses-options.ts +++ b/packages/ai/src/protocols/utils/open-responses-options.ts @@ -1,5 +1,6 @@ -import { Option, Schema } from "effect" +import { Schema } from "effect" import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js" +import { lenient } from "../shared.js" export { ReasoningEffort, ReasoningEfforts } @@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({ includeObfuscation: Schema.optional(Schema.Boolean), }) +// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`. export const Options = Schema.Struct({ - store: Schema.optional(Schema.Boolean), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), - safetyIdentifier: Schema.optional(Schema.String), - streamOptions: Schema.optional(StreamOptions), - topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))), - reasoningEffort: Schema.optional(ReasoningEffort), - reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])), - include: Schema.optional(Schema.Array(ResponseIncludableSchema)), - textVerbosity: Schema.optional(TextVerbositySchema), - serviceTier: Schema.optional(ServiceTierSchema), - truncation: Schema.optional(TruncationSchema), - allowedTools: Schema.optional(AllowedTools), - maxToolCalls: Schema.optional(Schema.Int), - parallelToolCalls: Schema.optional(Schema.Boolean), + store: lenient(Schema.Boolean), + metadata: lenient(Schema.Record(Schema.String, Schema.String)), + safetyIdentifier: lenient(Schema.String), + streamOptions: lenient(StreamOptions), + topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))), + reasoningEffort: lenient(ReasoningEffort), + reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])), + include: lenient(Schema.Array(ResponseIncludableSchema)), + textVerbosity: lenient(TextVerbositySchema), + serviceTier: lenient(ServiceTierSchema), + truncation: lenient(TruncationSchema), + allowedTools: lenient(AllowedTools), + maxToolCalls: lenient(Schema.Int), + parallelToolCalls: lenient(Schema.Boolean), }) export type Options = typeof Options.Type @@ -71,11 +73,10 @@ export type Resolved = Omit & { readonly allowedTools?: AllowedTools & { readonly mode: NonNullable } } -const decodeOptions = Schema.decodeUnknownOption(Options) +const decodeOptions = Schema.decodeUnknownSync(Options) export const resolve = (request: LLMRequest): Resolved => { - const input = Option.getOrUndefined(decodeOptions(request.providerOptions)) - if (!input) return {} + const input = decodeOptions(request.providerOptions ?? {}) return { ...input, include: input.include?.length ? input.include : undefined, diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 91c44b57d44..525cac3b689 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("keeps valid Chat options when a sibling option is malformed", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), + prompt: "think", + providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 }, + }), + ) + + expect(prepared.body.store).toBe(true) + expect(prepared.body.reasoning_effort).toBe("max") + }), + ) + it.effect("maps the request prompt cache key when the compatibility flag is set", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 9f8cbb37851..09acedbb2f7 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("drops a malformed provider option without discarding its siblings", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "hi", + providerOptions: { + topLogprobs: 25, + metadata: { tenant: 7 }, + reasoningEffort: "high", + serviceTier: "priority", + maxToolCalls: 4, + }, + }), + ) + + expect(prepared.body.top_logprobs).toBeUndefined() + expect(prepared.body.metadata).toBeUndefined() + expect(prepared.body.reasoning).toEqual({ effort: "high" }) + expect(prepared.body.service_tier).toBe("priority") + expect(prepared.body.max_tool_calls).toBe(4) + }), + ) + it.effect("accepts the full ResponseIncludable union", () => Effect.gen(function* () { const prepared = yield* compileRequest(