fix(api): emit reasoning_content on claude-web + v0-vercel-web SSE (#6662) (#6743)

* fix(api): emit reasoning_content on claude-web + v0-vercel-web /v1/chat/completions SSE (#6662)

* chore(6743): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 19:38:58 -03:00
committed by GitHub
parent e9d677055e
commit 0840722826
5 changed files with 244 additions and 16 deletions

View File

@@ -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).

View File

@@ -319,14 +319,36 @@ async function buildClaudeStreamingResponse(
try {
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
// 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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 {

View File

@@ -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<string, unknown>): boolean {
const reasoning =
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
? (body.reasoning as Record<string, unknown>)
: 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<string, unknown>).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<string, unknown> {
const delta: Record<string, string> =
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,
},

View File

@@ -67,10 +67,13 @@ export class V0VercelWebExecutor extends BaseExecutor {
if (!wantStream) {
const data = (await upstream.json()) as Record<string, unknown>;
const content =
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
(data?.content as string) ||
"";
const message = (data?.choices as Array<{ message?: Record<string, unknown> }>)?.[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<string, unknown> = { 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<string, string> = {};
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`));
}

View File

@@ -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<Uint8Array>({
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<Record<string, unknown>>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
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}`
);
});
});