mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* 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:
committed by
GitHub
parent
e9d677055e
commit
0840722826
1
changelog.d/fixes/6743-reasoning-content-web-sse.md
Normal file
1
changelog.d/fixes/6743-reasoning-content-web-sse.md
Normal 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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
152
tests/unit/issue-6662-repro.test.ts
Normal file
152
tests/unit/issue-6662-repro.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user