diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 76aaef65e..aecd2f2ea 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -1308,9 +1308,45 @@ function buildAdditionalModelRequestFields( return result; } + const candidates = getModelMatchCandidates(model.id, model.name); + + if (candidates.some((s) => s.includes("gpt-oss"))) { + return { reasoning_effort: OPENAI_GPT_OSS_EFFORT[options.reasoning] }; + } + + if (candidates.some((s) => s.includes("gpt-"))) { + const mapped = model.thinkingLevelMap?.[options.reasoning]; + return { + reasoning: { effort: typeof mapped === "string" ? mapped : OPENAI_GPT_EFFORT[options.reasoning] }, + }; + } + return undefined; } +type OpenAIGptEffort = "low" | "medium" | "high" | "xhigh" | "max"; +type OpenAIGptOssEffort = "low" | "medium" | "high"; + +/** OpenAI GPT models (GPT-5.x, GPT-6) take a nested `reasoning.effort` and reject `minimal`. */ +const OPENAI_GPT_EFFORT: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +/** gpt-oss takes a flat `reasoning_effort` and only accepts low, medium and high. */ +const OPENAI_GPT_OSS_EFFORT: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "high", + max: "high", +}; + function createImageBlock(mimeType: string, data: string) { let format: ImageFormat; switch (mimeType) { diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 122fafc84..c2711b2d0 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -14,6 +14,8 @@ interface BedrockThinkingPayload { }; output_config?: { effort?: string }; anthropic_beta?: string[]; + reasoning?: { effort?: string }; + reasoning_effort?: string; }; } @@ -197,6 +199,66 @@ describe("Bedrock thinking payload", () => { }); }); +describe("Bedrock OpenAI reasoning payload", () => { + // Regression for #9331: the configured thinking level never reached OpenAI models on Bedrock. + it.each([ + ["minimal", "low"], + ["low", "low"], + ["medium", "medium"], + ["high", "high"], + ["xhigh", "xhigh"], + ["max", "max"], + ] as const)("sends reasoning=%s as reasoning.effort=%s for GPT-6 and GPT-5.6", async (reasoning, effort) => { + for (const id of ["global.openai.gpt-6-sol", "us.openai.gpt-6-luna", "global.openai.gpt-5.6-sol"] as const) { + const payload = await capturePayload(getModel("amazon-bedrock", id), { reasoning }); + + expect(payload.additionalModelRequestFields, id).toEqual({ reasoning: { effort } }); + } + }); + + it("sends reasoning.effort when only model.name identifies a GPT model", async () => { + const model: Model<"bedrock-converse-stream"> = { + ...getModel("amazon-bedrock", "global.openai.gpt-6-sol"), + id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile", + name: "GPT-6 Sol", + }; + + const payload = await capturePayload(model, { reasoning: "medium" }); + + expect(payload.additionalModelRequestFields).toEqual({ reasoning: { effort: "medium" } }); + }); + + it("sends flat reasoning_effort for gpt-oss, clamped to high", async () => { + const model = getModel("amazon-bedrock", "openai.gpt-oss-120b-1:0"); + + expect((await capturePayload(model, { reasoning: "minimal" })).additionalModelRequestFields).toEqual({ + reasoning_effort: "low", + }); + expect((await capturePayload(model, { reasoning: "medium" })).additionalModelRequestFields).toEqual({ + reasoning_effort: "medium", + }); + expect((await capturePayload(model, { reasoning: "xhigh" })).additionalModelRequestFields).toEqual({ + reasoning_effort: "high", + }); + }); + + it("sends no reasoning fields when reasoning is off", async () => { + let captured: BedrockThinkingPayload | undefined; + const s = streamBedrock(getModel("amazon-bedrock", "global.openai.gpt-6-sol"), normalizeContext(makeContext()), { + onPayload: (payload) => { + captured = payload as BedrockThinkingPayload; + throw new PayloadCaptured(); + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + + expect(captured).toBeDefined(); + expect(captured?.additionalModelRequestFields).toBeUndefined(); + }); +}); + describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => { it( "uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models",