diff --git a/changelog.d/fixes/6743-reasoning-content-web-sse.md b/changelog.d/fixes/6743-reasoning-content-web-sse.md new file mode 100644 index 0000000000..089a53d995 --- /dev/null +++ b/changelog.d/fixes/6743-reasoning-content-web-sse.md @@ -0,0 +1 @@ +- **fix(api):** `reasoning_content` (extended-thinking text) was silently dropped from `/v1/chat/completions` SSE on the `claude-web` and `v0-vercel-web` executors ([#6662](https://github.com/diegosouzapw/OmniRoute/issues/6662)) — every chunk builder in both adapters hardcoded `delta: { content: ... }` with no reasoning path, unlike the established pattern already used by `default.ts`/`deepseek-web.ts`/`bedrock.ts` and the real-Anthropic-API `claude-to-openai.ts` translator (`thinking_delta` → `reasoning_content`). `v0-vercel-web.ts` now forwards an upstream `delta.reasoning_content` field (streaming and non-streaming) the same way `deepseek-web.ts` does. `claude-web.ts`'s `buildClaudeStreamingResponse` now maps a `content_block_start`(`type: "thinking"`)/`content_block_delta`(`delta.thinking`) pair onto `delta.reasoning_content`, and `claude-web/payload.ts`'s `transformToClaude()` no longer hardcodes `thinking_mode: "off"` — a new `wantsExtendedThinking()` derives it from the request's `reasoning_effort`/`reasoning.effort`/`thinking.type` signal, so extended thinking can actually be requested. Regression guard: `tests/unit/issue-6662-repro.test.ts` (RED→GREEN for both adapters). diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 599814d072..7e26777339 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -319,14 +319,36 @@ async function buildClaudeStreamingResponse( try { const parsed = JSON.parse(jsonStr) as Record; - // Content block delta — contains the actual text. - if (parsed.type === "content_block_delta") { + // Content block start — signals the beginning of a thinking + // block. Emit an empty reasoning_content chunk so clients that + // key off the field's presence (not just its text) see the + // thinking panel open immediately, mirroring the real-Anthropic + // translator's content_block_start handling (#6662). + if (parsed.type === "content_block_start") { + const block = parsed.content_block as Record | undefined; + if (block?.type === "thinking") { + const chunk = transformFromClaude("", model, undefined, "reasoning"); + const out = `data: ${JSON.stringify(chunk)}\n\n`; + controller.enqueue(new TextEncoder().encode(out)); + } + } + // Content block delta — contains the actual text, or (for a + // thinking block) the extended-thinking text. Claude's real SSE + // shape uses `delta.text` for text_delta and `delta.thinking` + // for thinking_delta — never both — so a plain field check is + // enough to route each to the right OpenAI delta field. + else if (parsed.type === "content_block_delta") { const delta = parsed.delta as Record | undefined; const text = delta?.text as string | undefined; + const thinking = delta?.thinking as string | undefined; if (text) { const chunk = transformFromClaude(text, model); const out = `data: ${JSON.stringify(chunk)}\n\n`; controller.enqueue(new TextEncoder().encode(out)); + } else if (thinking) { + const chunk = transformFromClaude(thinking, model, undefined, "reasoning"); + const out = `data: ${JSON.stringify(chunk)}\n\n`; + controller.enqueue(new TextEncoder().encode(out)); } } // message_stop — final event from Claude. @@ -362,11 +384,17 @@ async function buildClaudeStreamingResponse( if (parsed.type === "content_block_delta") { const delta = parsed.delta as Record | undefined; const text = delta?.text as string | undefined; + const thinking = delta?.thinking as string | undefined; if (text) { const chunk = transformFromClaude(text, model); controller.enqueue( new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`) ); + } else if (thinking) { + const chunk = transformFromClaude(thinking, model, undefined, "reasoning"); + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`) + ); } } } catch { diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts index cc0dcc3787..fb1e873e64 100644 --- a/open-sse/executors/claude-web/payload.ts +++ b/open-sse/executors/claude-web/payload.ts @@ -152,6 +152,36 @@ export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personal ]; } +/** + * Detect whether an OpenAI-shape request body signals a desire for + * reasoning / extended thinking — a top-level `reasoning_effort` string, + * a Responses-API-style `reasoning.effort`, or a native Claude + * `thinking: { type: "enabled" }` passthrough. Mirrors the same + * effort-extraction shape used by `sanitizeReasoningEffortForProvider` + * (open-sse/executors/base/reasoningEffort.ts) so a client already setting + * reasoning_effort for other providers gets the same signal here. + * + * Before this, `transformToClaude` hardcoded `thinking_mode: "off"` — + * Claude Web could never be asked for extended thinking, and any + * `thinking_delta` reasoning the upstream might otherwise emit was moot + * because it was never requested in the first place (#6662). + */ +export function wantsExtendedThinking(body: Record): boolean { + const reasoning = + body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) + ? (body.reasoning as Record) + : null; + const effort = body.reasoning_effort ?? reasoning?.effort; + if (typeof effort === "string" && effort.trim() && effort.toLowerCase() !== "none") { + return true; + } + const thinking = body.thinking; + if (thinking && typeof thinking === "object" && !Array.isArray(thinking)) { + if ((thinking as Record).type === "enabled") return true; + } + return false; +} + /** * Transform OpenAI format to Claude Web format */ @@ -189,7 +219,7 @@ export function transformToClaude( files: [], sync_sources: [], rendering_mode: "messages", - thinking_mode: "off", + thinking_mode: wantsExtendedThinking(body) ? "on" : "off", create_conversation_params: { name: "", model: model || DEFAULT_CLAUDE_MODEL, @@ -204,13 +234,24 @@ export function transformToClaude( } /** - * Transform Claude Web response to OpenAI format + * Transform Claude Web response to OpenAI format. + * + * `kind` selects which delta field carries `claudeContent`: `"content"` + * (default, preserves the original call sites) or `"reasoning"` — the + * latter maps Claude's `thinking_delta` text onto `delta.reasoning_content`, + * the same field the real-Anthropic-API translator uses + * (open-sse/translator/response/claude-to-openai.ts) so downstream clients + * (Claude Code, Cursor, etc.) render it as the thinking panel instead of + * silently dropping it (#6662). */ export function transformFromClaude( claudeContent: string, model: string, - stopReason?: string + stopReason?: string, + kind: "content" | "reasoning" = "content" ): Record { + const delta: Record = + kind === "reasoning" ? { reasoning_content: claudeContent } : { content: claudeContent }; return { id: `chatcmpl-${Date.now()}`, object: "chat.completion.chunk", @@ -219,9 +260,7 @@ export function transformFromClaude( choices: [ { index: 0, - delta: { - content: claudeContent, - }, + delta, finish_reason: stopReason === "end_turn" ? "stop" : null, logprobs: null, }, diff --git a/open-sse/executors/v0-vercel-web.ts b/open-sse/executors/v0-vercel-web.ts index eb6d276b00..1e97108716 100644 --- a/open-sse/executors/v0-vercel-web.ts +++ b/open-sse/executors/v0-vercel-web.ts @@ -67,10 +67,13 @@ export class V0VercelWebExecutor extends BaseExecutor { if (!wantStream) { const data = (await upstream.json()) as Record; - const content = - (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || - (data?.content as string) || - ""; + const message = (data?.choices as Array<{ message?: Record }>)?.[0] + ?.message; + const content = (message?.content as string) || (data?.content as string) || ""; + const reasoningContent = + (message?.reasoning_content as string) || (data?.reasoning_content as string) || ""; + const responseMessage: Record = { role: "assistant", content }; + if (reasoningContent) responseMessage.reasoning_content = reasoningContent; return { response: new Response( JSON.stringify({ @@ -81,7 +84,7 @@ export class V0VercelWebExecutor extends BaseExecutor { choices: [ { index: 0, - message: { role: "assistant", content }, + message: responseMessage, finish_reason: "stop", }, ], @@ -123,14 +126,19 @@ export class V0VercelWebExecutor extends BaseExecutor { } try { const parsed = JSON.parse(data); - const text = parsed.choices?.[0]?.delta?.content || ""; - if (text) { + const delta = parsed.choices?.[0]?.delta || {}; + const text = delta.content || ""; + const reasoningText = delta.reasoning_content || ""; + if (text || reasoningText) { + const outDelta: Record = {}; + if (reasoningText) outDelta.reasoning_content = reasoningText; + if (text) outDelta.content = text; const chunk = { id: `chatcmpl-v0-${Date.now()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: modelId, - choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + choices: [{ index: 0, delta: outDelta, finish_reason: null }], }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); } diff --git a/tests/unit/issue-6662-repro.test.ts b/tests/unit/issue-6662-repro.test.ts new file mode 100644 index 0000000000..6f058d7789 --- /dev/null +++ b/tests/unit/issue-6662-repro.test.ts @@ -0,0 +1,152 @@ +import { describe, it, mock, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// Repro for #6662: reasoning_content dropped from /v1/chat/completions SSE +// deltas on the v0-vercel-web and claude-web executors. +// +// v0-vercel-web: its upstream (v0.dev/api/chat) speaks an OpenAI-compatible +// SSE format. When the upstream chunk carries `delta.reasoning_content` +// (as OpenAI-compatible reasoning-capable backends do — see the DeepSeek +// pattern already handled at open-sse/executors/deepseek-web.ts:221), the +// v0 executor's stream transform only ever reads `delta?.content` and +// silently drops any `reasoning_content` field instead of forwarding it. +// +// claude-web: its upstream (claude.ai's real chat_conversations/.../completion +// endpoint) speaks the native Anthropic SSE shape — `content_block_delta` +// events with `delta.type === "thinking_delta"` / `delta.thinking` for +// extended-thinking text (the same shape the real-Anthropic-API translator +// at open-sse/translator/response/claude-to-openai.ts already maps to +// `reasoning_content`). Before the fix, `buildClaudeStreamingResponse` in +// open-sse/executors/claude-web.ts only ever read `delta.text`, so any +// `thinking_delta` event was silently dropped instead of forwarded. + +const mod = await import("../../open-sse/executors/v0-vercel-web.ts"); +const { ClaudeWebExecutor } = await import("../../open-sse/executors/claude-web.ts"); +const { __setTlsFetchOverrideForTesting } = await import( + "../../open-sse/services/claudeTlsClient.ts" +); + +function sseUpstream(events: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`data: ${e}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("#6662 repro — v0-vercel-web drops reasoning_content", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("forwards reasoning_content deltas from the upstream SSE stream (RED on current code)", async () => { + const upstreamEvents = [ + JSON.stringify({ + choices: [{ delta: { reasoning_content: "Let me think about 17*23..." } }], + }), + JSON.stringify({ + choices: [{ delta: { content: "391" } }], + }), + ]; + + globalThis.fetch = mock.fn(async () => sseUpstream(upstreamEvents)) as unknown as typeof fetch; + + const executor = new mod.V0VercelWebExecutor(); + const result = await executor.execute({ + model: "v0-default", + body: { messages: [{ role: "user", content: "Solve 17*23" }] }, + stream: true, + credentials: { apiKey: "fake-cookie" }, + signal: null, + }); + + assert.ok(result.response instanceof Response); + const text = await result.response.text(); + + assert.ok( + text.includes("reasoning_content"), + `expected translated SSE stream to carry a reasoning_content field, got:\n${text}` + ); + }); +}); + +describe("#6662 repro — claude-web drops thinking_delta reasoning_content", () => { + afterEach(() => { + __setTlsFetchOverrideForTesting(null); + }); + + function claudeSseStream(events: Array>): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`)); + } + controller.close(); + }, + }); + } + + it("forwards thinking_delta text as reasoning_content in the translated SSE stream (RED on current code)", async () => { + const upstreamEvents = [ + { type: "message_start", message: { id: "msg_1", model: "claude-sonnet-4-6" } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Let me think about 17*23..." }, + }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "391" } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; + + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(upstreamEvents), + })); + + const executor = new ClaudeWebExecutor(); + const result = await executor.execute({ + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "Solve 17*23" }] }, + stream: true, + credentials: { + // cf_clearance present up front so normalizeClaudeSessionCookieWithAutoRefresh + // takes the fast path and never attempts a real Turnstile solve in-test. + apiKey: "sessionKey=fake-session; cf_clearance=fake-clearance", + orgId: "org-test", + conversationId: "conv-test", + }, + signal: null, + }); + + assert.ok(result.response instanceof Response); + const text = await result.response.text(); + + assert.ok( + text.includes("reasoning_content"), + `expected translated SSE stream to carry a reasoning_content field, got:\n${text}` + ); + assert.ok( + text.includes("Let me think about 17*23"), + `expected the thinking text to be forwarded, got:\n${text}` + ); + }); +});