mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) (#12688)
* fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) OpenRouter thinking models return both a "reasoning" string and a "reasoning_details[].text" array for the same thinking trace. OmniRoute's reasoning promotion gated on "is any readable value present" (which includes the "reasoning" alias), so reasoning_content was never populated and clients like opencode that only read reasoning_content lost all thinking traces. Encrypted-only reasoning_details items are left intact (not flattened). Fixes all three promotion gates: - non-streaming: copyOpenAICompatibleReasoningFields now mirrors reasoning_details[].text into reasoning_content unless reasoning_content itself is present - streaming mirror block: same gate fix on getReadableReasoningValue - streaming passthrough: force re-serialization when sanitize added a reasoning_content the upstream delta did not carry (needsReserialization was false because hasUnsupportedReasoningSignal requires !readable) Tests: non-streaming + streaming unit regressions and an integration E2E that drives the full handleChat path against a mock OpenRouter provider. Also closes the same latent gate in the JSON-to-SSE rehydrator (jsonToSse.ts buildReasoningDelta): a populated reasoning string used to short-circuit the unsupported-alias mirror, so reasoning_details[].text was dropped when synthesizing an SSE stream from a non-streaming JSON body. Adds a #12665 regression test for that path, fixes an over-indented brace in stream.ts (lint), and restores the missing trailing newline in the E2E. * docs(changelog): add fragment for #12688 — fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present * fix(tests): replace any with typed casts in #12665 regressions to satisfy no-explicit-any gate
This commit is contained in:
1
changelog.d/fixes/12688-open-sse-reasoning-promotion.md
Normal file
1
changelog.d/fixes/12688-open-sse-reasoning-promotion.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(open-sse):** `reasoning_details[].text` is now promoted to `reasoning_content` even when `reasoning` is also present, so OpenRouter thinking models (GLM-5.3-Flash, DeepSeek-V4-Flash, Kimi K3) no longer lose their thinking traces in clients that only read `reasoning_content` ([#12688](https://github.com/diegosouzapw/OmniRoute/pull/12688) — thanks @thomasmaerz)
|
||||
@@ -55,7 +55,16 @@ function buildReasoningDelta(message: JsonRecord): JsonRecord | null {
|
||||
delta.reasoning_details = message.reasoning_details;
|
||||
}
|
||||
|
||||
if (!addReadableReasoning(message, delta)) {
|
||||
// Always emit the readable field (reasoning_content, else the reasoning
|
||||
// alias) WITHOUT short-circuiting the unsupported-alias mirror below.
|
||||
addReadableReasoning(message, delta);
|
||||
|
||||
// Mirror unsupported reasoning aliases (reasoning_text / thinking / thought /
|
||||
// reasoning_details[].text) into reasoning_content unless reasoning_content
|
||||
// itself is present — same gate as copyOpenAICompatibleReasoningFields
|
||||
// (#12665). A populated `reasoning` string must NOT skip this: OpenRouter
|
||||
// thinking models send BOTH `reasoning` and `reasoning_details[].text`.
|
||||
if (!nonEmptyString(message.reasoning_content)) {
|
||||
addUnsupportedReasoning(message, delta);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,14 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target:
|
||||
if (source.thinking !== undefined) target.thinking = source.thinking;
|
||||
if (source.thought !== undefined) target.thought = source.thought;
|
||||
if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details;
|
||||
if (!getReadableReasoningValue(target)) {
|
||||
// Mirror unsupported reasoning aliases (reasoning_text / thinking / thought /
|
||||
// reasoning_details[].text) into the client-readable reasoning_content field.
|
||||
// Only the presence of an existing reasoning_content blocks this — NOT the
|
||||
// `reasoning` string. OpenRouter thinking models return BOTH `reasoning` and
|
||||
// `reasoning_details[].text`; previously `reasoning` alone short-circuited the
|
||||
// promotion, so reasoning_content was never set and thinking traces were lost
|
||||
// for clients (e.g. opencode) that only read reasoning_content.
|
||||
if (!nonEmptyString(target.reasoning_content)) {
|
||||
const mirrored = getUnsupportedReasoningValue(source);
|
||||
if (mirrored) target.reasoning_content = mirrored;
|
||||
}
|
||||
|
||||
@@ -1906,6 +1906,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed?.id != null && typeof parsed.id !== "string";
|
||||
const rawDelta = parsed.choices?.[0]?.delta;
|
||||
const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta);
|
||||
const hadUpstreamReasoningContent =
|
||||
typeof rawDelta?.reasoning_content === "string" &&
|
||||
rawDelta.reasoning_content.length > 0;
|
||||
|
||||
if (!projectedFailure) {
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
@@ -1967,12 +1970,22 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
|
||||
// Track whether we need to re-serialize (separate from injectedUsage
|
||||
// to avoid blocking subsequent finish_reason / usage mutations)
|
||||
// to avoid blocking subsequent finish_reason / usage mutations).
|
||||
// sanitizeStreamingChunk above can MIRROR reasoning_details[].text
|
||||
// into reasoning_content when the upstream only sent `reasoning`
|
||||
// (OpenRouter thinking models, #12665). hadReasoningAlias covers
|
||||
// reasoning_text/thinking/thought aliases, but a populated `reasoning`
|
||||
// string makes hasUnsupportedReasoningSignal return false — so we also
|
||||
// force a re-serialize when sanitize added a reasoning_content that the
|
||||
// upstream delta did not already carry.
|
||||
const needsReserialization =
|
||||
splitMixedReasoningContent ||
|
||||
thinkParsed ||
|
||||
hadReasoningAlias ||
|
||||
(delta?.content === "" && delta?.reasoning_content);
|
||||
(delta?.content === "" && delta?.reasoning_content) ||
|
||||
(!hadUpstreamReasoningContent &&
|
||||
typeof delta?.reasoning_content === "string" &&
|
||||
delta.reasoning_content.length > 0);
|
||||
|
||||
// T18: Track if we saw tool calls & accumulate for call log
|
||||
if (delta?.tool_calls && delta.tool_calls.length > 0) {
|
||||
@@ -2244,7 +2257,17 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
);
|
||||
}
|
||||
// Mirror only client-unsupported reasoning aliases into `reasoning_content`.
|
||||
if (!openAiReasoning) {
|
||||
// Gate on reasoning_content being ABSENT (not on getReadableReasoningValue
|
||||
// which also includes the `reasoning` string): OpenRouter thinking models
|
||||
// return BOTH `reasoning` and `reasoning_details[].text`, and `reasoning`
|
||||
// alone previously skipped the mirror, dropping thinking traces for clients
|
||||
// that only read `reasoning_content` (#12665).
|
||||
const openAiReasoningContent =
|
||||
typeof openAiDelta?.reasoning_content === "string" &&
|
||||
openAiDelta.reasoning_content.length > 0
|
||||
? openAiDelta.reasoning_content
|
||||
: "";
|
||||
if (!openAiReasoningContent) {
|
||||
const delta = openAiDelta;
|
||||
const r = getUnsupportedReasoningValue(delta);
|
||||
if (typeof r === "string" && r.length > 0) {
|
||||
|
||||
233
tests/integration/openrouter-reasoning-details-e2e.test.ts
Normal file
233
tests/integration/openrouter-reasoning-details-e2e.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-openrouter-reasoning-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-openrouter-reasoning-secret";
|
||||
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
|
||||
const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { resetAllCircuitBreakers } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
|
||||
|
||||
type FetchCall = {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function toPlainHeaders(headers: HeadersInit | undefined | null) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequest(url: string, overrides: RequestInit = {}) {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
...((overrides.headers as Record<string, string>) || {}),
|
||||
});
|
||||
return new Request(url, { ...overrides, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter-shaped non-streaming completion: the provider returns BOTH a
|
||||
* `reasoning` string AND a `reasoning_details[]` array carrying the same
|
||||
* thinking text. This is exactly what DeepSeek V4 / GLM 5.3 / Kimi K3 return
|
||||
* through OpenRouter (#12665).
|
||||
*/
|
||||
function buildOpenRouterStreamingSse({
|
||||
thinking = "Hmm, let me think this through",
|
||||
content = "Visible answer",
|
||||
} = {}) {
|
||||
const chunk = (delta: Record<string, unknown>) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_openrouter_reasoning_stream",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1783636289,
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
choices: [
|
||||
{ index: 0, delta, finish_reason: null, logprobs: null },
|
||||
],
|
||||
})}\n\n`;
|
||||
return (
|
||||
chunk({ reasoning: thinking, reasoning_details: [{ type: "reasoning.text", text: thinking }] }) +
|
||||
chunk({ content }) +
|
||||
chunk({}) +
|
||||
chunk({}) +
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenRouterResponse({
|
||||
content = "Visible answer",
|
||||
thinking = "Hmm, let me think this through",
|
||||
} = {}) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl_openrouter_reasoning",
|
||||
object: "chat.completion",
|
||||
created: 1783636289,
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content,
|
||||
reasoning: thinking,
|
||||
reasoning_details: [{ type: "reasoning.text", text: thinking }],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
test.before(async () => {
|
||||
await initTranslators();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
|
||||
BaseExecutor.freeze?.();
|
||||
clearInflight();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test("openrouter provider: reasoning_details[].text is mirrored to reasoning_content even when reasoning string is present", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "openrouter-reasoning-e2e",
|
||||
apiKey: "sk-mock-openrouter-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
|
||||
});
|
||||
|
||||
const fetchCalls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = async (input, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(input),
|
||||
method: init.method || "GET",
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenRouterResponse();
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "openrouter/auto",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Think through this carefully." }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content?: unknown;
|
||||
reasoning?: unknown;
|
||||
reasoning_content?: unknown;
|
||||
reasoning_details?: unknown;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200, JSON.stringify(json));
|
||||
assert.equal(fetchCalls.length, 1, "should make exactly one upstream call");
|
||||
assert.match(fetchCalls[0].url, /mock-openrouter\.invalid/, fetchCalls[0].url);
|
||||
|
||||
const message = json.choices[0].message;
|
||||
assert.equal(message.content, "Visible answer");
|
||||
// The client-readable field must be populated from reasoning_details[].text
|
||||
// even though the `reasoning` alias is also present (#12665).
|
||||
assert.equal(message.reasoning_content, "Hmm, let me think this through");
|
||||
assert.equal(message.reasoning, "Hmm, let me think this through");
|
||||
assert.deepEqual(message.reasoning_details, [
|
||||
{ type: "reasoning.text", text: "Hmm, let me think this through" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("openrouter provider: streaming deltas carry reasoning_content from reasoning_details[].text", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "openrouter-reasoning-stream-e2e",
|
||||
apiKey: "sk-mock-openrouter-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
|
||||
});
|
||||
|
||||
let fetched = false;
|
||||
globalThis.fetch = async (input, init: RequestInit = {}) => {
|
||||
void input;
|
||||
void init;
|
||||
fetched = true;
|
||||
return new Response(buildOpenRouterStreamingSse(), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "openrouter/auto",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "Think through this carefully." }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const raw = await response.text();
|
||||
assert.equal(response.status, 200, raw);
|
||||
assert.equal(fetched, true, "should make exactly one upstream call");
|
||||
|
||||
const chunks = raw.split("\n\n").filter((line) => line.startsWith("data: "));
|
||||
const payloads = chunks
|
||||
.map((line) => line.replace(/^data: /, ""))
|
||||
.filter((json) => json !== "[DONE]")
|
||||
.map((json) => JSON.parse(json) as {
|
||||
choices?: Array<{ delta?: Record<string, unknown> }>;
|
||||
});
|
||||
|
||||
const reasoningContentDeltas = payloads
|
||||
.map((payload) => payload.choices?.[0]?.delta?.reasoning_content)
|
||||
.filter((content): content is string => Boolean(content));
|
||||
|
||||
assert.equal(reasoningContentDeltas.length, 1, JSON.stringify(payloads));
|
||||
assert.equal(reasoningContentDeltas[0], "Hmm, let me think this through");
|
||||
});
|
||||
@@ -131,6 +131,37 @@ describe("synthesizeOpenAiSseFromJson (#3089)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("#12665: reasoning present does NOT suppress reasoning_details text in reasoning_content", () => {
|
||||
const sse = synthesizeOpenAiSseFromJson(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
reasoning: "client-readable reasoning string",
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: "details thinking trace" },
|
||||
],
|
||||
content: "final text",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const deltas = parseDataChunks(sse)
|
||||
.filter((c) => c !== "[DONE]")
|
||||
.map((c) => JSON.parse(c).choices[0].delta);
|
||||
|
||||
// reasoning alias is preserved AND reasoning_content is populated from
|
||||
// reasoning_details[].text (previously the alias short-circuited the mirror).
|
||||
const rc = deltas.find((d) => d.reasoning_content !== undefined)?.reasoning_content;
|
||||
assert.equal(rc, "details thinking trace");
|
||||
assert.equal(
|
||||
deltas.find((d) => d.reasoning !== undefined)?.reasoning,
|
||||
"client-readable reasoning string"
|
||||
);
|
||||
});
|
||||
|
||||
test("forwards tool_calls in the delta", () => {
|
||||
const sse = synthesizeOpenAiSseFromJson(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -285,6 +285,79 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeOpenAIResponse promotes reasoning_details text to reasoning_content even when reasoning is also present", () => {
|
||||
// OpenRouter returns BOTH a `reasoning` string AND a `reasoning_details[]`
|
||||
// array with the same thinking text for DeepSeek V4 / GLM 5.3 / Kimi K3.
|
||||
// Clients (opencode) only read reasoning_content, so the details text must be
|
||||
// mirrored into reasoning_content regardless of the `reasoning` alias being
|
||||
// present (#12665).
|
||||
const sanitized = sanitizeOpenAIResponse({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Visible answer",
|
||||
reasoning: "Hmm, let me think this through",
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: "Hmm, let me think this through" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const message = (
|
||||
sanitized as {
|
||||
choices: Array<{
|
||||
message: {
|
||||
reasoning?: unknown;
|
||||
reasoning_content?: unknown;
|
||||
reasoning_details?: unknown;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
).choices[0].message;
|
||||
assert.equal(message.reasoning, "Hmm, let me think this through");
|
||||
assert.equal(message.reasoning_content, "Hmm, let me think this through");
|
||||
assert.deepEqual(message.reasoning_details, [
|
||||
{ type: "reasoning.text", text: "Hmm, let me think this through" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizeOpenAIResponse does not flatten signature-only reasoning_details into reasoning_content", () => {
|
||||
// Regression guard for the flip side: non-text details entries (encrypted
|
||||
// signatures) must NOT be coerced into reasoning_content text (#12665).
|
||||
const sanitized = sanitizeOpenAIResponse({
|
||||
model: "openrouter/moonshotai/kimi-k3",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Visible answer",
|
||||
reasoning: "native reasoning",
|
||||
reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const message = (
|
||||
sanitized as {
|
||||
choices: Array<{
|
||||
message: {
|
||||
reasoning?: unknown;
|
||||
reasoning_content?: unknown;
|
||||
reasoning_details?: unknown;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
).choices[0].message;
|
||||
assert.equal(message.reasoning_content, undefined);
|
||||
assert.equal(message.reasoning, "native reasoning");
|
||||
assert.deepEqual(message.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]);
|
||||
});
|
||||
|
||||
test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => {
|
||||
const sanitized = sanitizeOpenAIResponse({
|
||||
model: "openrouter/model",
|
||||
@@ -533,6 +606,39 @@ test("sanitizeStreamingChunk preserves client-readable reasoning deltas", () =>
|
||||
assert.equal((sanitized as any).choices[0].delta.reasoning_content, undefined);
|
||||
});
|
||||
|
||||
test("sanitizeStreamingChunk promotes reasoning_details text when reasoning is also present in the delta", () => {
|
||||
// Streaming parity for #12665: OpenRouter streams reasoning_details[].text
|
||||
// chunks alongside a `reasoning` string; reasoning_content must still be
|
||||
// populated for the client.
|
||||
const sanitized = sanitizeStreamingChunk({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning: "thinking chunk",
|
||||
reasoning_details: [{ type: "reasoning.text", text: "thinking chunk" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const delta = (
|
||||
sanitized as {
|
||||
choices: Array<{
|
||||
delta: {
|
||||
reasoning?: unknown;
|
||||
reasoning_content?: unknown;
|
||||
reasoning_details?: unknown;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
).choices[0].delta;
|
||||
assert.equal(delta.reasoning, "thinking chunk");
|
||||
assert.equal(delta.reasoning_content, "thinking chunk");
|
||||
assert.deepEqual(delta.reasoning_details, [
|
||||
{ type: "reasoning.text", text: "thinking chunk" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizeStreamingChunk preserves and mirrors Copilot reasoning_text deltas", () => {
|
||||
const sanitized = sanitizeStreamingChunk({
|
||||
choices: [
|
||||
|
||||
Reference in New Issue
Block a user