From 20b3acc9514cee96f30d1634ea4d62b847f6c874 Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Wed, 23 Sep 2026 00:13:56 -0300 Subject: [PATCH 1/4] fix(provider): complete support for Claude Opus 5.5 and GPT-6 --- .changeset/new-models-support.md | 5 + .../openai-responses-language-model.ts | 15 +- .../openai-responses-language-model.test.ts | 45 ++++++ packages/llm/src/providers/openai-options.ts | 3 +- .../test/provider/openai-responses.test.ts | 16 ++ packages/redcode/src/plugin/openai/codex.ts | 15 +- packages/redcode/src/provider/provider.ts | 4 +- packages/redcode/src/provider/transform.ts | 36 ++++- packages/redcode/src/session/prompt.ts | 41 +++++- packages/redcode/test/plugin/codex.test.ts | 15 +- .../redcode/test/provider/provider.test.ts | 47 ++++++ .../redcode/test/provider/transform.test.ts | 139 +++++++++++++++++- packages/redcode/test/session/prompt.test.ts | 72 +++++++++ 13 files changed, 424 insertions(+), 29 deletions(-) create mode 100644 .changeset/new-models-support.md diff --git a/.changeset/new-models-support.md b/.changeset/new-models-support.md new file mode 100644 index 000000000000..45f65f5e8f09 --- /dev/null +++ b/.changeset/new-models-support.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Claude Opus 5.5 and GPT-6 models now work with the same defaults as their predecessors. GPT-6 Sol, Luna and Astra get medium reasoning effort, reasoning summaries and encrypted reasoning on OpenAI, Azure, GitHub Copilot and OpenCode Zen, plus the Codex subscription context limits. Claude Opus 5.5 shows summarized thinking without picking a variant, and structured output on Opus 5.5, Fable and Mythos asks for the StructuredOutput tool instead of forcing it, which those models reject. GPT Luna is now the preferred small model for titles and summaries. diff --git a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts index 8df1dcedadb3..dc8b43c93baa 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts @@ -1671,14 +1671,15 @@ type ResponsesModelConfig = { } function getResponsesModelConfig(modelId: string): ResponsesModelConfig { + // GPT-5 and every later generation (gpt-6-sol, gpt-6-luna, ...) follow the gpt-5 rules. + const gpt5OrNewer = Number(/^gpt-(\d+)/.exec(modelId)?.[1]) >= 5 + const gptChat = /^gpt-\d+-chat/.test(modelId) const supportsFlexProcessing = - modelId.startsWith("o3") || - modelId.startsWith("o4-mini") || - (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat")) + modelId.startsWith("o3") || modelId.startsWith("o4-mini") || (gpt5OrNewer && !gptChat) const supportsPriorityProcessing = modelId.startsWith("gpt-4") || modelId.startsWith("gpt-5-mini") || - (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-nano") && !modelId.startsWith("gpt-5-chat")) || + (gpt5OrNewer && !/^gpt-\d+-nano/.test(modelId) && !gptChat) || modelId.startsWith("o3") || modelId.startsWith("o4-mini") const defaults = { @@ -1688,8 +1689,8 @@ function getResponsesModelConfig(modelId: string): ResponsesModelConfig { supportsPriorityProcessing, } - // gpt-5-chat models are non-reasoning - if (modelId.startsWith("gpt-5-chat")) { + // gpt-5-chat and later gpt-N-chat models are non-reasoning + if (gpt5OrNewer && gptChat) { return { ...defaults, isReasoningModel: false, @@ -1699,7 +1700,7 @@ function getResponsesModelConfig(modelId: string): ResponsesModelConfig { // o series reasoning models: if ( modelId.startsWith("o") || - modelId.startsWith("gpt-5") || + gpt5OrNewer || modelId.startsWith("codex-") || modelId.startsWith("computer-use") ) { diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts index 28e47ee5d560..c6e3156272ae 100644 --- a/packages/core/test/github-copilot/openai-responses-language-model.test.ts +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -79,6 +79,51 @@ describe("doGenerate", () => { }) }) +describe("reasoning model classification", () => { + test.each([ + ["gpt-5.5", true], + ["gpt-6-sol", true], + ["gpt-6-luna", true], + ["gpt-5-chat-latest", false], + ["gpt-6-chat-latest", false], + ["gpt-4.1", false], + ])("%s is a reasoning model: %p", async (modelId, reasoning) => { + const mockFetch = createMockFetch({ + id: "resp_1", + created_at: 0, + model: modelId, + output: [ + { + type: "message", + role: "assistant", + id: "msg_1", + content: [{ type: "output_text", text: "Hello there", annotations: [] }], + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + const model = new OpenAIResponsesLanguageModel(modelId, { + provider: "copilot", + url: () => "https://api.test.com/responses", + headers: () => ({ Authorization: "Bearer test-token" }), + fetch: mockFetch as any, + }) + + await model.doGenerate({ + prompt: [{ role: "system", content: "Be brief." }, ...TEST_PROMPT], + temperature: 0.5, + providerOptions: { copilot: { reasoningEffort: "high" } }, + includeRawChunks: false, + } as any) + + const calls = mockFetch.mock.calls as unknown as [string, RequestInit][] + const body = JSON.parse(String(calls[0][1].body)) + expect(body.reasoning).toEqual(reasoning ? { effort: "high" } : undefined) + expect(body.temperature).toEqual(reasoning ? undefined : 0.5) + expect(body.input[0].role).toBe(reasoning ? "developer" : "system") + }) +}) + describe("convertToOpenAIResponsesInput", () => { test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => { const { input } = await convertToOpenAIResponsesInput({ diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts index fb548dd79726..292101c8cb4b 100644 --- a/packages/llm/src/providers/openai-options.ts +++ b/packages/llm/src/providers/openai-options.ts @@ -46,7 +46,8 @@ export const gpt5DefaultOptions = ( options: { readonly textVerbosity?: boolean } = {}, ): ProviderOptions | undefined => { const id = modelID.toLowerCase() - if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined + // GPT-5 and every later generation (gpt-6-sol, gpt-6-luna, ...) share these defaults. + if (!(Number(/gpt-(\d+)/.exec(id)?.[1]) >= 5) || /gpt-\d+-(?:chat|pro)/.test(id)) return undefined return openAIProviderOptions({ reasoningEffort: "medium", reasoningSummary: "auto", diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af47..3e09c041a982 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -659,6 +659,22 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("applies the GPT-5 reasoning defaults to GPT-6 models", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-6-sol"), + prompt: "hi", + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" }) + expect(prepared.body.text).toBeUndefined() + }), + ) + it.effect("lets callers opt out of the GPT-5 default include", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/redcode/src/plugin/openai/codex.ts b/packages/redcode/src/plugin/openai/codex.ts index c8b4723e5ad0..9d08874fbe5c 100644 --- a/packages/redcode/src/plugin/openai/codex.ts +++ b/packages/redcode/src/plugin/openai/codex.ts @@ -312,14 +312,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output: 0, cache: { read: 0, write: 0 }, }, - limit: - model.id.includes("gpt-5.5") || model.id.includes("gpt-5.6") - ? { - context: 400_000, - input: 272_000, - output: 128_000, - } - : model.limit, + limit: ["gpt-5.5", "gpt-5.6", "gpt-6-"].some((prefix) => model.id.includes(prefix)) + ? { + context: 400_000, + input: 272_000, + output: 128_000, + } + : model.limit, }, ]), ) diff --git a/packages/redcode/src/provider/provider.ts b/packages/redcode/src/provider/provider.ts index ac17c05b93e3..b78f8408e229 100644 --- a/packages/redcode/src/provider/provider.ts +++ b/packages/redcode/src/provider/provider.ts @@ -2066,7 +2066,7 @@ const layer = Layer.effect( } const priority = providerID.startsWith("opencode") - ? ["gpt-nano"] + ? ["gpt-luna", "gpt-nano"] : providerID.startsWith("github-copilot") ? ["gpt-mini", ...smallModelFamilyPriority] : smallModelFamilyPriority @@ -2142,7 +2142,7 @@ const layer = Layer.effect( ) const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] -const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] +const smallModelFamilyPriority = ["gpt-luna", "gemini-flash", "gpt-nano", "claude-haiku"] export function sort(models: T[]) { return sortBy( models, diff --git a/packages/redcode/src/provider/transform.ts b/packages/redcode/src/provider/transform.ts index 9708bf4c8f04..8da766a6e42d 100644 --- a/packages/redcode/src/provider/transform.ts +++ b/packages/redcode/src/provider/transform.ts @@ -740,13 +740,13 @@ function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: an switch (model.api.npm) { case "@ai-sdk/anthropic": case "@ai-sdk/google-vertex/anthropic": { - const thinking = options.thinking ?? { type: "adaptive" } + const thinking = options.thinking ?? adaptiveThinkingDefault(model.api.id) if (thinking.type !== "adaptive" && thinking.type !== "enabled") return options if (thinking.blockBinding !== undefined) return options return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } } } case "@ai-sdk/amazon-bedrock": { - const reasoningConfig = options.reasoningConfig ?? { type: "adaptive" } + const reasoningConfig = options.reasoningConfig ?? adaptiveThinkingDefault(model.api.id) if (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled") return options if (reasoningConfig.blockBinding !== undefined) return options return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } } @@ -755,6 +755,23 @@ function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: an return options } +// Claude 4.7+ omits thinking text unless asked, so a request without a variant would stream none. +function adaptiveThinkingDefault(apiId: string) { + return { type: "adaptive", ...(anthropicOmitsThinking(apiId) ? { display: "summarized" } : {}) } +} + +// Claude Opus 5.5 and the Fable and Mythos models always think, and the API answers a forced +// tool_choice ("any" or a named tool) with a 400 for them. +export function supportsForcedToolChoice(model: Provider.Model) { + const id = model.api.id.toLowerCase() + if (!id.includes("claude-")) return true + if (/(?:^|[^a-z])(?:fable|mythos)(?:[^a-z]|$)/.test(id)) return false + const version = /claude-(?:([a-z]+)-)?(\d+)(?:[.-](\d{1,2}))?(?:-([a-z]+))?(?:[.@-]|$)/.exec(id) + if (!version || (version[1] ?? version[4]) !== "opus") return true + const major = Number(version[2]) + return major < 5 || (major === 5 && Number(version[3] ?? 0) < 5) +} + function googleThinkingLevelEfforts(apiId: string) { const id = apiId.toLowerCase() if (!id.includes("gemini-3")) return ["low", "high"] @@ -1348,8 +1365,9 @@ export function options(input: { // Any gpt version above 5.4 in combination with azure does not support reasoningEffort // so we should return early here. - const [, gptMajorVersion, gptMinorVersion] = input.model.api.id.match(/gpt-(\d+)\.(\d+)/) ?? [] - const isGpt55OrNewer = Number(gptMajorVersion) > 5 || (Number(gptMajorVersion) === 5 && Number(gptMinorVersion) >= 5) + const [, gptMajorVersion, gptMinorVersion] = input.model.api.id.match(/gpt-(\d+)(?:\.(\d+))?/) ?? [] + const isGpt55OrNewer = + Number(gptMajorVersion) > 5 || (Number(gptMajorVersion) === 5 && Number(gptMinorVersion ?? 0) >= 5) if (input.model.api.npm === "@ai-sdk/azure" && input.providerOptions?.useCompletionUrls) { if (!isGpt55OrNewer) { result["reasoningEffort"] = "medium" @@ -1357,8 +1375,8 @@ export function options(input: { return result } - if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { - if (!input.model.api.id.includes("gpt-5-pro")) { + if (openaiReasoningGeneration(input.model.api.id)) { + if (!/gpt-\d+-pro/.test(input.model.api.id)) { result["reasoningEffort"] = "medium" if ( input.model.api.npm === "@ai-sdk/openai" || @@ -1396,6 +1414,12 @@ export function options(input: { return result } +// GPT-5 and every later generation (gpt-6-sol, gpt-6-luna, ...) share the reasoning defaults; +// gpt-N-chat ids are the non-reasoning chat snapshots. +function openaiReasoningGeneration(apiId: string) { + return Number(/gpt-(\d+)/.exec(apiId)?.[1]) >= 5 && !/gpt-\d+-chat/.test(apiId) +} + export function smallOptions(model: Provider.Model) { const small = Object.values(model.variants ?? {})[0] ?? {} if ( diff --git a/packages/redcode/src/session/prompt.ts b/packages/redcode/src/session/prompt.ts index 1005f86e51a9..b00e5ad3d5cd 100644 --- a/packages/redcode/src/session/prompt.ts +++ b/packages/redcode/src/session/prompt.ts @@ -201,6 +201,8 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +const STRUCTURED_OUTPUT_REMINDER = `Your last response was plain text, but structured output was requested. Call the StructuredOutput tool now with your final answer formatted according to the schema.` + function mcpResourceBase64Size(value: string) { const trimmed = value.replace(/\s/g, "") const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 @@ -1608,6 +1610,8 @@ const layer = Layer.effect( let todoContinuations = 0 let reconnects = 0 let responseRepairs = 0 + // Reminders sent to a model that cannot be forced to call StructuredOutput and answered in text. + let structuredReminders = 0 const promptAssessments = new Map() const intelligenceAttempts = new Map() const toolAssessments = new Map() @@ -1643,6 +1647,7 @@ const layer = Layer.effect( todoContinuations = 0 reconnects = 0 responseRepairs = 0 + structuredReminders = 0 reviewed = undefined ineffectiveCompactions = 0 overflowRecoveries = 0 @@ -2786,7 +2791,13 @@ const layer = Layer.effect( messages: stepMessages, tools, model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + // Models that always think reject a forced tool choice; they are asked for the tool instead. + toolChoice: + format.type === "json_schema" + ? ProviderTransform.supportsForcedToolChoice(model) + ? "required" + : "auto" + : undefined, estimate: requestEstimate, // System One already chose this turn's tools and skills; a RedRouter must not choose again. // Its hint lets a RedRouter combo pick the model for the turn; Redcode never switches it. @@ -2841,9 +2852,35 @@ const layer = Layer.effect( return "break" as const } if (format.type === "json_schema") { + // Without a forced tool choice the model may answer in text; remind it before failing. + if ( + !ProviderTransform.supportsForcedToolChoice(model) && + structuredReminders < (format.retryCount ?? 2) + ) { + structuredReminders++ + const reminder: SessionV1.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + format: lastUser.format, + } + yield* sessions.updateMessage(reminder) + yield* sessions.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: reminder.id, + type: "text", + text: STRUCTURED_OUTPUT_REMINDER, + synthetic: true, + }) + return "continue" as const + } handle.message.error = new SessionV1.StructuredOutputError({ message: "Model did not produce structured output", - retries: 0, + retries: structuredReminders, }).toObject() yield* sessions.updateMessage(handle.message) return "break" as const diff --git a/packages/redcode/test/plugin/codex.test.ts b/packages/redcode/test/plugin/codex.test.ts index 159f7806a75e..788d8f0818d3 100644 --- a/packages/redcode/test/plugin/codex.test.ts +++ b/packages/redcode/test/plugin/codex.test.ts @@ -289,7 +289,17 @@ describe("plugin.codex", () => { const provider = { models: { ...Object.fromEntries( - ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [ + [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.7-pro", + "gpt-6-sol", + "gpt-6-luna", + "gpt-6-astra", + ].map((id) => [ id, { id, api: { id }, limit, cost: {}, options: {} }, ]), @@ -318,6 +328,9 @@ describe("plugin.codex", () => { expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-6-sol"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-6-luna"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-6-astra"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(models["gpt-5.4-pro"]).toBeUndefined() expect(models["gpt-5.7-pro"]).toBeDefined() expect(models["gpt-5.6-sol-high"]).toBeDefined() diff --git a/packages/redcode/test/provider/provider.test.ts b/packages/redcode/test/provider/provider.test.ts index 60341b22e782..6d0006ee389f 100644 --- a/packages/redcode/test/provider/provider.test.ts +++ b/packages/redcode/test/provider/provider.test.ts @@ -820,6 +820,53 @@ it.instance( }, ) +it.instance( + "getSmallModel prefers GPT Luna", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("gpt-6-luna")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "gemini-flash": { family: "gemini-flash", release_date: "2026-09-01" }, + "gpt-5-nano": { family: "gpt-nano", release_date: "2026-01-01" }, + "gpt-6-luna": { family: "gpt-luna", release_date: "2026-09-22" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel prefers GPT Luna over GPT Nano on OpenCode providers", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("opencode-test")) + expect(model?.id).toBe(ModelV2.ID.make("gpt-6-luna")) + }), + { + config: { + provider: { + "opencode-test": { + name: "OpenCode Test", + npm: "@ai-sdk/openai-compatible", + models: { + "gpt-5-nano": { family: "gpt-nano", release_date: "2026-09-23" }, + "gpt-6-luna": { family: "gpt-luna", release_date: "2026-09-22" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + it.instance( "getSmallModel ignores model IDs without family metadata", Effect.gen(function* () { diff --git a/packages/redcode/test/provider/transform.test.ts b/packages/redcode/test/provider/transform.test.ts index 2e0e165ccef5..d9bf3091c597 100644 --- a/packages/redcode/test/provider/transform.test.ts +++ b/packages/redcode/test/provider/transform.test.ts @@ -627,6 +627,40 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) expect(result.textVerbosity).toBeUndefined() }) + + test.each(["gpt-6-sol", "gpt-6-luna", "gpt-6-astra"])("%s gets the GPT-5 reasoning defaults on OpenAI", (id) => { + const result = ProviderTransform.options({ model: createGpt5Model(id), sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBe("auto") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + // Low verbosity stays a gpt-5.x default. + expect(result.textVerbosity).toBeUndefined() + }) + + test("gpt-6-sol on OpenCode Zen gets the cache key and encrypted reasoning", () => { + const model = { + ...createGpt5Model("gpt-6-sol"), + id: "opencode/gpt-6-sol", + providerID: "opencode", + api: { id: "gpt-6-sol", url: "https://opencode.ai/zen/v1", npm: "@ai-sdk/openai-compatible" }, + } + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBe("auto") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test.each(["gpt-6-chat", "gpt-6-pro"])("%s keeps the chat and pro exclusions", (id) => { + const result = ProviderTransform.options({ model: createGpt5Model(id), sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-4.1 gets no reasoning defaults", () => { + const result = ProviderTransform.options({ model: createGpt5Model("gpt-4.1"), sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBeUndefined() + expect(result.include).toBeUndefined() + }) }) describe("ProviderTransform.options - gpt-5 reasoningEffort", () => { @@ -716,6 +750,72 @@ describe("ProviderTransform.options - gpt-5 reasoningEffort", () => { expect(result.reasoningEffort).toBe("medium") }) + + test("gpt-6-sol counts as newer than gpt-5.5 for the completions API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-6-sol"), + sessionID, + providerOptions: { useCompletionUrls: true }, + }) + + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-5-mini still sets reasoningEffort for the completions API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5-mini"), + sessionID, + providerOptions: { useCompletionUrls: true }, + }) + + expect(result.reasoningEffort).toBe("medium") + }) + + test("gpt-6-sol gets the reasoning defaults for the responses API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-6-sol"), + sessionID, + providerOptions: {}, + }) + + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBe("auto") + expect(result.include).toBeUndefined() + }) +}) + +describe("ProviderTransform.supportsForcedToolChoice", () => { + const claude = (id: string) => ({ api: { id } }) as any + + test.each([ + "claude-opus-5-5", + "claude-opus-5.5", + "anthropic/claude-opus-5.5", + "anthropic.claude-opus-5-5", + "us.anthropic.claude-opus-5-5-v1:0", + "claude-opus-5-5@default", + "claude-5-5-opus", + "claude-opus-6", + "claude-fable-5-1", + "claude-fable-5", + "claude-mythos-5-1", + "global.anthropic.claude-mythos-5-2-v1:0", + ])("rejects forced tool choice for %s", (id) => { + expect(ProviderTransform.supportsForcedToolChoice(claude(id))).toBe(false) + }) + + test.each([ + "claude-opus-5", + "claude-opus-5-1", + "claude-opus-5-20260724", + "claude-opus-4-8", + "claude-sonnet-5-5", + "claude-haiku-4-5", + "gpt-6-sol", + "test-model", + ])("allows forced tool choice for %s", (id) => { + expect(ProviderTransform.supportsForcedToolChoice(claude(id))).toBe(true) + }) }) describe("ProviderTransform.options - gateway", () => { @@ -887,13 +987,19 @@ describe("ProviderTransform.providerOptions", () => { "claude-sonnet-5-2", "claude-mythos-5-2", "claude-mythos-5-10", + "claude-opus-5-5", + "claude-opus-5.5", "claude-opus-6", "claude-6-opus", "claude-mythos-6-20270901", ])("adds binding for %s", (id) => { const model = claude(sdk.npm, id) + // Without a variant the default thinking still asks for summarized text, which these models omit. expect(ProviderTransform.providerOptions(model, {})).toEqual({ - [sdk.key]: { [sdk.option]: { type: "adaptive", blockBinding: binding } }, + [sdk.key]: { [sdk.option]: { type: "adaptive", display: "summarized", blockBinding: binding } }, + }) + expect(ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "omitted" } })).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive", display: "omitted", blockBinding: binding } }, }) expect( ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "summarized" } }), @@ -1039,6 +1145,35 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("sends summarized adaptive thinking for Claude Opus 5.5 without a variant", async () => { + const requests: Request[] = [] + const capture = Object.assign( + async (...args: Parameters) => { + requests.push(new Request(...args)) + return Response.json({ + type: "message", + id: "msg_1", + model: "claude-opus-5-5", + role: "assistant", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }) + }, + { preconnect: () => undefined }, + ) + const model = claude("@ai-sdk/anthropic", "claude-opus-5-5") + await generateText({ + model: createAnthropic({ apiKey: "test-key", fetch: capture })(model.api.id), + prompt: "hi", + maxOutputTokens: 32000, + providerOptions: ProviderTransform.providerOptions(model, {}), + }) + expect(requests).toHaveLength(1) + const body = await requests[0].json() + expect(body.thinking).toMatchObject({ type: "adaptive", display: "summarized" }) + }) + test("leaves disabled thinking alone", () => { const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") expect(ProviderTransform.providerOptions(model, { thinking: { type: "disabled" } })).toEqual({ @@ -1061,7 +1196,7 @@ describe("ProviderTransform.providerOptions", () => { bedrock: { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high", blockBinding: binding } }, }) expect(ProviderTransform.providerOptions(model, {})).toEqual({ - bedrock: { reasoningConfig: { type: "adaptive", blockBinding: binding } }, + bedrock: { reasoningConfig: { type: "adaptive", display: "summarized", blockBinding: binding } }, }) }) diff --git a/packages/redcode/test/session/prompt.test.ts b/packages/redcode/test/session/prompt.test.ts index 6b140b4ed388..567074126e57 100644 --- a/packages/redcode/test/session/prompt.test.ts +++ b/packages/redcode/test/session/prompt.test.ts @@ -8678,3 +8678,75 @@ it.instance( }).pipe(Effect.ensuring(forgetTestModel)), 90_000, ) + +/** The test model served as a Claude model through the real Anthropic SDK against the fake server. */ +function claudeCfg(url: string, id: string) { + const base = sdkCfg(url, "@ai-sdk/anthropic", { context: 100_000, output: 32_000 }) + return { + ...base, + provider: { + ...base.provider, + test: { ...base.provider.test, models: { "test-model": { ...base.provider.test.models["test-model"], id } } }, + }, + } +} + +const promptStructured = Effect.fn("test.promptStructured")(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Structured output" }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "What is 2 + 2?" }], + format: { + type: "json_schema", + schema: { type: "object", properties: { answer: { type: "number" } }, required: ["answer"] }, + retryCount: 2, + }, + }) + return { chat, prompt, sessions } +}) + +const structuredOf = (messages: SessionV1.WithParts[]) => { + const answer = messages.findLast((message) => message.info.role === "assistant") + return answer?.info.role === "assistant" ? answer.info.structured : undefined +} + +it.instance( + "structured output on Claude Opus 5.5 asks for the tool instead of forcing it", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => claudeCfg(url, "claude-opus-5-5")) + const { chat, prompt, sessions } = yield* promptStructured() + yield* llm.text("It is 4.") + yield* llm.tool("StructuredOutput", { answer: 4 }) + yield* awaitWithTimeout(prompt.loop({ sessionID: chat.id }), "the loop never finished", "60 seconds") + + const hits = yield* llm.hits + expect(hits).toHaveLength(2) + hits.forEach((hit) => expect(hit.body.tool_choice).toMatchObject({ type: "auto" })) + // The plain-text answer was followed by a reminder to call the tool. + expect(requestText(hits[1]!)).toContain("Call the StructuredOutput tool now") + expect(structuredOf(yield* sessions.messages({ sessionID: chat.id }))).toEqual({ answer: 4 }) + }), + 60_000, +) + +it.instance( + "structured output on Claude Opus 5 still forces the tool", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => claudeCfg(url, "claude-opus-5")) + const { chat, prompt, sessions } = yield* promptStructured() + yield* llm.tool("StructuredOutput", { answer: 4 }) + yield* awaitWithTimeout(prompt.loop({ sessionID: chat.id }), "the loop never finished", "60 seconds") + + const hits = yield* llm.hits + expect(hits).toHaveLength(1) + expect(hits[0]!.body.tool_choice).toMatchObject({ type: "any" }) + expect(structuredOf(yield* sessions.messages({ sessionID: chat.id }))).toEqual({ answer: 4 }) + }), + 60_000, +) From c30e9e70146356a3d9557db8e2377ae546e325e5 Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Wed, 23 Sep 2026 00:17:47 -0300 Subject: [PATCH 2/4] test(provider): expect summarized display on default Fable thinking --- packages/redcode/test/provider/transform.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/redcode/test/provider/transform.test.ts b/packages/redcode/test/provider/transform.test.ts index d9bf3091c597..99d86577ffad 100644 --- a/packages/redcode/test/provider/transform.test.ts +++ b/packages/redcode/test/provider/transform.test.ts @@ -1245,6 +1245,7 @@ describe("ProviderTransform.providerOptions", () => { }) expect(sent?.body.thinking).toEqual({ type: "adaptive", + display: "summarized", block_binding: { prefix_mismatch_behavior: "drop_block" }, }) expect(sent?.headers.get("anthropic-beta")?.split(",")).toContain("thinking-binding-controls-2026-08-01") @@ -1294,6 +1295,7 @@ describe("ProviderTransform.providerOptions", () => { expect(sent?.body.anthropic_version).toBe("vertex-2023-10-16") expect(sent?.body.thinking).toEqual({ type: "adaptive", + display: "summarized", block_binding: { prefix_mismatch_behavior: "drop_block" }, }) expect(sent?.headers.get("anthropic-beta")?.split(",")).toContain("thinking-binding-controls-2026-08-01") From 271fcf230d14df582c2b6d7080dbf248fd610bce Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Wed, 23 Sep 2026 00:22:02 -0300 Subject: [PATCH 3/4] test(session): build structured output format as a schema class --- packages/redcode/test/session/prompt.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/redcode/test/session/prompt.test.ts b/packages/redcode/test/session/prompt.test.ts index 567074126e57..01a001bdece2 100644 --- a/packages/redcode/test/session/prompt.test.ts +++ b/packages/redcode/test/session/prompt.test.ts @@ -8700,11 +8700,11 @@ const promptStructured = Effect.fn("test.promptStructured")(function* () { agent: "build", noReply: true, parts: [{ type: "text", text: "What is 2 + 2?" }], - format: { + format: new SessionV1.OutputFormatJsonSchema({ type: "json_schema", schema: { type: "object", properties: { answer: { type: "number" } }, required: ["answer"] }, retryCount: 2, - }, + }), }) return { chat, prompt, sessions } }) From 8100a05f286f940472c0205fc11ef01d76f7089d Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Wed, 23 Sep 2026 00:27:00 -0300 Subject: [PATCH 4/4] fix(session): decode stored structured output format before republishing --- packages/redcode/src/session/prompt.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/redcode/src/session/prompt.ts b/packages/redcode/src/session/prompt.ts index b00e5ad3d5cd..cb0f47fb2bb1 100644 --- a/packages/redcode/src/session/prompt.ts +++ b/packages/redcode/src/session/prompt.ts @@ -201,6 +201,10 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +// Stored messages read back as plain JSON, but publishing a user message encodes its format as the +// schema class, so a stored format is decoded again before the message is republished. +const decodeFormat = Schema.decodeUnknownSync(SessionV1.Format) + const STRUCTURED_OUTPUT_REMINDER = `Your last response was plain text, but structured output was requested. Call the StructuredOutput tool now with your final answer formatted according to the schema.` function mcpResourceBase64Size(value: string) { @@ -1434,6 +1438,7 @@ const layer = Layer.effect( const info: SessionV1.User = { ...message.value.info, time: { ...message.value.info.time, created: DateTime.toEpochMillis(now) }, + ...(message.value.info.format ? { format: decodeFormat(message.value.info.format) } : {}), } yield* sessions.updateMessage(info) yield* events.publish(SessionV1.Event.MessagePromoted, { sessionID, messageID }) @@ -2865,7 +2870,7 @@ const layer = Layer.effect( time: { created: Date.now() }, agent: lastUser.agent, model: lastUser.model, - format: lastUser.format, + format: decodeFormat(format), } yield* sessions.updateMessage(reminder) yield* sessions.updatePart({