From de9f0d1bfe37147d10c3189fb7015b9dcda45e41 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:58:16 +0000 Subject: [PATCH] fix(ai): parse compatible reasoning deltas --- packages/ai/src/protocols/openai-chat.ts | 45 +++++++++++-- .../recordings/openrouter-reasoning.json | 34 ++++++++++ .../vercel-ai-gateway-reasoning.json | 34 ++++++++++ .../openai-chat-reasoning.recorded.test.ts | 67 +++++++++++++++++++ packages/ai/test/provider/openai-chat.test.ts | 48 +++++++------ packages/core/src/models-dev.ts | 3 +- packages/core/src/v1/config/provider.ts | 6 +- packages/core/test/config/provider-v1.test.ts | 11 +++ 8 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 packages/ai/test/fixtures/recordings/openrouter-reasoning.json create mode 100644 packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json create mode 100644 packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts create mode 100644 packages/core/test/config/provider-v1.test.ts diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index c74ab7fd6426..101ac61cf62c 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -75,6 +75,8 @@ const OpenAIChatMessage = Schema.Union([ content: Schema.NullOr(Schema.String), tool_calls: optionalArray(OpenAIChatAssistantToolCall), reasoning_content: Schema.optional(Schema.String), + reasoning: Schema.optional(Schema.String), + reasoning_text: Schema.optional(Schema.String), }), Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), ]).pipe(Schema.toTaggedUnion("role")) @@ -145,6 +147,8 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined +const reasoningField = (part: ReasoningPart) => { + const field = part.providerMetadata?.openai?.reasoningField + if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field + return "reasoning_content" +} + const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { const content: Array> = [] for (const part of message.content) { @@ -248,14 +259,20 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func continue } } + const text = reasoning.map((part) => part.text).join("") + const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content" return { role: "assistant" as const, content: content.length === 0 ? null : ProviderShared.joinText(content), tool_calls: toolCalls.length === 0 ? undefined : toolCalls, reasoning_content: - reasoning.length > 0 - ? reasoning.map((part) => part.text).join("") - : openAICompatibleReasoningContent(message.native?.openaiCompatible), + reasoning.length === 0 + ? openAICompatibleReasoningContent(message.native?.openaiCompatible) + : field === "reasoning_content" + ? text + : undefined, + reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined, + reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined, } }) @@ -400,6 +417,12 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { }) } +const reasoningDelta = (delta: Schema.Schema.Type | null | undefined) => { + if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const + if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const + if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const +} + const step = (state: ParserState, event: OpenAIChatEvent) => Effect.gen(function* () { const events: LLMEvent[] = [] @@ -412,8 +435,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) => let lifecycle = state.lifecycle - if (delta?.reasoning_content) - lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) + const reasoning = reasoningDelta(delta) + const reasoningField = state.reasoningField ?? reasoning?.field + if (reasoning) + lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, { + openai: { reasoningField: reasoningField ?? reasoning.field }, + }) if (delta?.content) { lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") @@ -450,6 +477,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => usage, finishReason, lifecycle, + reasoningField, }, events, ] as const @@ -482,7 +510,12 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }), + initial: () => ({ + tools: ToolStream.empty(), + toolCallEvents: [], + lifecycle: Lifecycle.initial(), + reasoningField: undefined, + }), step, onHalt: finishEvents, }, diff --git a/packages/ai/test/fixtures/recordings/openrouter-reasoning.json b/packages/ai/test/fixtures/recordings/openrouter-reasoning.json new file mode 100644 index 000000000000..46f8a28296d6 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/openrouter-reasoning.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "model": "anthropic/claude-sonnet-4.6", + "tags": [ + "prefix:openai-compatible-chat", + "provider:openrouter", + "protocol:openai-chat", + "reasoning" + ], + "name": "openrouter-reasoning", + "recordedAt": "2026-07-18T11:28:39.267Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json b/packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json new file mode 100644 index 000000000000..9cf18c3909ed --- /dev/null +++ b/packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "model": "anthropic/claude-sonnet-4.6", + "tags": [ + "prefix:openai-compatible-chat", + "provider:vercel-ai-gateway", + "protocol:openai-chat", + "reasoning" + ], + "name": "vercel-ai-gateway-reasoning", + "recordedAt": "2026-07-18T11:28:42.077Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://ai-gateway.vercel.sh/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"enabled\":true,\"max_tokens\":1024}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"signature\":\"\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460\",\"signature\":\"\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\" - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"signature\":\"\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDNiOTNhNWRkLTczMDItNDgzZi1hZWFlLTM2MjA3NTU0OGFlMRIMKkQaBkOSYB8cgQNiGgwDinVmv/KYSAKQEsUiMPSSFWvVpNuyEyYC8HlxrEZsb5KEEETuMjAI2hcC3m/NwGR+PC7chh2JWwD7wyK+eyp6OoF973UNMHAsWsKykCSJv60aXeOiDomxdfR9CRWVtaroVTkhtL2FPgplBPZYr75XvS0l6If3fqCPKevNE5WaOsSaXNfnMCCKGX7A0Pkhs4NazmCnntWGOsW7J03bAQKzIZ+c+Yr0rSwmwvuiocDqzsSE8bOeVv4352EYAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37,887\"},\"logprobs\":null,\"finish_reason\":null}],\"system_fingerprint\":\"fp_zfth1fcyet\"}\n\ndata: {\"id\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\",\"object\":\"chat.completion.chunk\",\"created\":1784374121,\"model\":\"anthropic/claude-sonnet-4.6\",\"choices\":[{\"index\":0,\"delta\":{\"provider_metadata\":{\"anthropic\":{\"usage\":{\"input_tokens\":61,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":81,\"service_tier\":\"standard\",\"inference_geo\":\"global\",\"output_tokens_details\":{\"thinking_tokens\":73}},\"cacheCreationInputTokens\":0,\"stopSequence\":null,\"iterations\":null,\"container\":null,\"contextManagement\":null},\"gateway\":{\"routing\":{\"originalModelId\":\"anthropic/claude-sonnet-4.6\",\"resolvedProvider\":\"anthropic\",\"fallbacksAvailable\":[\"vertexAnthropic\",\"bedrock\"],\"planningReasoning\":\"System credentials planned for: anthropic, vertexAnthropic, bedrock. Total execution order: anthropic(system) → vertexAnthropic(system) → bedrock(system)\",\"canonicalSlug\":\"anthropic/claude-sonnet-4.6\",\"finalProvider\":\"anthropic\",\"modelAttemptCount\":1,\"modelAttempts\":[{\"canonicalSlug\":\"anthropic/claude-sonnet-4.6\",\"success\":true,\"providerAttemptCount\":1,\"providerAttempts\":[{\"provider\":\"anthropic\",\"credentialType\":\"system\",\"success\":true,\"startTime\":1784374119500,\"endTime\":1784374122066,\"providerRequestId\":\"req_011Cd9SjHykKUqTQ1S8cAG17\",\"statusCode\":200,\"providerResponseId\":\"msg_011Cd9SjLvMmTFY8Pyj8n336\"}]}],\"totalProviderAttemptCount\":1},\"cost\":\"0.001398\",\"marketCost\":\"0.001398\",\"surchargeCost\":\"0\",\"gatewayCost\":\"0.001398\",\"inferenceCost\":\"0.001398\",\"inputInferenceCost\":\"0.000183\",\"outputInferenceCost\":\"0.001215\",\"generationId\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\"}}},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":81,\"total_tokens\":142,\"cost\":0.001398,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":null,\"upstream_inference_prompt_cost\":0,\"upstream_inference_completions_cost\":0},\"completion_tokens_details\":{\"reasoning_tokens\":26,\"reasoning_tokens_estimated\":true,\"image_tokens\":0},\"cache_creation_input_tokens\":0,\"market_cost\":0.001398,\"gateway_cost\":0.001398},\"system_fingerprint\":\"fp_zfth1fcyet\",\"generationId\":\"gen_01KXTFRJXJ8CKP0W3DKC3WC004\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts new file mode 100644 index 000000000000..32ef7820b8e6 --- /dev/null +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMEvent } from "../../src" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenRouter from "../../src/providers/openrouter" +import { LLMClient } from "../../src/route" +import { recordedTests } from "../recorded-test" + +const cases = [ + { + name: "OpenRouter", + model: OpenRouter.configure({ + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", + providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + requires: ["OPENROUTER_API_KEY"], + cassette: "openrouter-reasoning", + }, + { + name: "Vercel AI Gateway", + model: OpenAICompatible.configure({ + provider: "vercel-ai-gateway", + baseURL: "https://ai-gateway.vercel.sh/v1", + apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture", + http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + requires: ["AI_GATEWAY_API_KEY"], + cassette: "vercel-ai-gateway-reasoning", + }, +] as const + +for (const item of cases) { + const recorded = recordedTests({ + prefix: "openai-compatible-chat", + provider: item.model.provider, + protocol: "openai-chat", + requires: item.requires, + tags: ["reasoning"], + metadata: { model: item.model.id }, + }) + + describe(`${item.name} reasoning recorded`, () => { + recorded.effect.with( + "streams scalar reasoning", + { cassette: item.cassette }, + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: item.model, + system: "Think through the arithmetic, then reply with only the final integer.", + prompt: "What is 173 multiplied by 219?", + generation: { maxTokens: 1536, temperature: 0 }, + }), + ) + + expect(response.text.replaceAll(",", "").trim()).toBe("37887") + expect(response.reasoning.length).toBeGreaterThan(0) + expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true) + expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ + openai: { reasoningField: "reasoning" }, + }) + }), + 30_000, + ) + }) +} diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index a08a22b03733..e2efa1717d88 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -540,29 +540,33 @@ describe("OpenAI Chat route", () => { }), ) - it.effect("parses OpenAI-compatible reasoning content deltas", () => + it.effect("parses and replays OpenAI-compatible reasoning fields", () => Effect.gen(function* () { - const body = sseEvents( - { choices: [{ delta: { reasoning_content: "thinking" } }] }, - { choices: [{ delta: { content: "Hello" } }] }, - { choices: [{ delta: {}, finish_reason: "stop" }] }, - ) - - const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) - - expect(response.reasoning).toBe("thinking") - expect(response.text).toBe("Hello") - expect(response.events).toMatchObject([ - { type: "step-start", index: 0 }, - { type: "reasoning-start", id: "reasoning-0" }, - { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, - { type: "reasoning-end", id: "reasoning-0" }, - { type: "text-start", id: "text-0" }, - { type: "text-delta", id: "text-0", text: "Hello" }, - { type: "text-end", id: "text-0" }, - { type: "step-finish", index: 0, reason: "stop" }, - { type: "finish", reason: "stop" }, - ]) + const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const + for (const field of fields) { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { choices: [{ delta: { [field]: "thinking" } }] }, + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("thinking") + expect(response.text).toBe("Hello") + expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ + openai: { reasoningField: field }, + }) + + const replay = yield* LLMClient.prepare( + LLM.request({ model, messages: [response.message] }), + ) + expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }]) + } }), ) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0caf414cce64..640b5b108580 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -35,6 +35,7 @@ type ReasoningOption = | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } type Modality = "text" | "audio" | "image" | "video" | "pdf" +type InterleavedField = "reasoning" | "reasoning_content" | "reasoning_text" | "reasoning_details" | (string & {}) type SourceModel = { readonly id: string @@ -46,7 +47,7 @@ type SourceModel = { readonly reasoning_options?: readonly ReasoningOption[] readonly temperature?: boolean readonly tool_call: boolean - readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" } + readonly interleaved?: true | { readonly field: InterleavedField } readonly cost?: Cost readonly limit: { readonly context: number; readonly input?: number; readonly output: number } readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..b199cbffa323 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -4,6 +4,10 @@ import { Schema } from "effect" import { PositiveInt } from "../../schema" export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +const InterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text", "reasoning_details"]), + Schema.String, +]) export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -18,7 +22,7 @@ export const Model = Schema.Struct({ Schema.Union([ Schema.Literal(true), Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: InterleavedField, }), ]), ), diff --git a/packages/core/test/config/provider-v1.test.ts b/packages/core/test/config/provider-v1.test.ts new file mode 100644 index 000000000000..902c9ab4ef8b --- /dev/null +++ b/packages/core/test/config/provider-v1.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider" +import { Schema } from "effect" + +const decode = Schema.decodeUnknownSync(ConfigProviderV1.Model) + +test("accepts known and custom interleaved reasoning fields", () => { + const fields = ["reasoning", "reasoning_content", "reasoning_text", "reasoning_details", "vendor_reasoning"] + + for (const field of fields) expect(decode({ interleaved: { field } }).interleaved).toEqual({ field }) +})