Skip to content

Commit fc6a74c

Browse files
committed
feat: add reasoning_content support for OpenRouter chat completions
OpenRouter returns reasoning via reasoning_content in the chat completions message/delta format. This adds support for emitting and collapsing reasoning_content in both streaming and non-streaming responses.
1 parent 7aac519 commit fc6a74c

6 files changed

Lines changed: 194 additions & 5 deletions

File tree

src/__tests__/reasoning-web-search.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,125 @@ describe("POST /v1/messages (thinking blocks non-streaming)", () => {
549549
expect(body.content[0].type).toBe("text");
550550
});
551551
});
552+
553+
// ─── Chat Completions: reasoning_content (OpenRouter format) ────────────────
554+
555+
interface ChatCompletionChunk {
556+
id: string;
557+
object: string;
558+
created: number;
559+
model: string;
560+
choices: {
561+
index: number;
562+
delta: { role?: string; content?: string | null; reasoning_content?: string };
563+
finish_reason: string | null;
564+
}[];
565+
}
566+
567+
function parseChatCompletionSSEChunks(body: string): ChatCompletionChunk[] {
568+
const chunks: ChatCompletionChunk[] = [];
569+
for (const line of body.split("\n")) {
570+
if (line.startsWith("data: ") && line.slice(6).trim() !== "[DONE]") {
571+
chunks.push(JSON.parse(line.slice(6)) as ChatCompletionChunk);
572+
}
573+
}
574+
return chunks;
575+
}
576+
577+
describe("POST /v1/chat/completions (reasoning_content streaming)", () => {
578+
it("emits reasoning_content deltas before content deltas", async () => {
579+
instance = await createServer(allFixtures);
580+
const res = await post(`${instance.url}/v1/chat/completions`, {
581+
model: "gpt-4",
582+
messages: [{ role: "user", content: "think" }],
583+
stream: true,
584+
});
585+
586+
expect(res.status).toBe(200);
587+
const chunks = parseChatCompletionSSEChunks(res.body);
588+
589+
const reasoningChunks = chunks.filter((c) => c.choices[0]?.delta.reasoning_content);
590+
const contentChunks = chunks.filter(
591+
(c) => c.choices[0]?.delta.content && c.choices[0].delta.content.length > 0,
592+
);
593+
594+
expect(reasoningChunks.length).toBeGreaterThan(0);
595+
expect(contentChunks.length).toBeGreaterThan(0);
596+
597+
// All reasoning chunks appear before all content chunks
598+
const lastReasoningIdx = chunks.lastIndexOf(reasoningChunks[reasoningChunks.length - 1]);
599+
const firstContentIdx = chunks.indexOf(contentChunks[0]);
600+
expect(lastReasoningIdx).toBeLessThan(firstContentIdx);
601+
});
602+
603+
it("reasoning_content deltas reconstruct full reasoning text", async () => {
604+
instance = await createServer(allFixtures);
605+
const res = await post(`${instance.url}/v1/chat/completions`, {
606+
model: "gpt-4",
607+
messages: [{ role: "user", content: "think" }],
608+
stream: true,
609+
});
610+
611+
const chunks = parseChatCompletionSSEChunks(res.body);
612+
const reasoning = chunks.map((c) => c.choices[0]?.delta.reasoning_content ?? "").join("");
613+
expect(reasoning).toBe("Let me think step by step about this problem.");
614+
});
615+
616+
it("content deltas still reconstruct full text", async () => {
617+
instance = await createServer(allFixtures);
618+
const res = await post(`${instance.url}/v1/chat/completions`, {
619+
model: "gpt-4",
620+
messages: [{ role: "user", content: "think" }],
621+
stream: true,
622+
});
623+
624+
const chunks = parseChatCompletionSSEChunks(res.body);
625+
const content = chunks.map((c) => c.choices[0]?.delta.content ?? "").join("");
626+
expect(content).toBe("The answer is 42.");
627+
});
628+
629+
it("no reasoning_content when reasoning is absent", async () => {
630+
instance = await createServer(allFixtures);
631+
const res = await post(`${instance.url}/v1/chat/completions`, {
632+
model: "gpt-4",
633+
messages: [{ role: "user", content: "plain" }],
634+
stream: true,
635+
});
636+
637+
const chunks = parseChatCompletionSSEChunks(res.body);
638+
const reasoningChunks = chunks.filter((c) => c.choices[0]?.delta.reasoning_content);
639+
expect(reasoningChunks).toHaveLength(0);
640+
});
641+
});
642+
643+
describe("POST /v1/chat/completions (reasoning_content non-streaming)", () => {
644+
it("includes reasoning_content in non-streaming response", async () => {
645+
instance = await createServer(allFixtures);
646+
const res = await post(`${instance.url}/v1/chat/completions`, {
647+
model: "gpt-4",
648+
messages: [{ role: "user", content: "think" }],
649+
stream: false,
650+
});
651+
652+
expect(res.status).toBe(200);
653+
const body = JSON.parse(res.body);
654+
expect(body.object).toBe("chat.completion");
655+
expect(body.choices[0].message.content).toBe("The answer is 42.");
656+
expect(body.choices[0].message.reasoning_content).toBe(
657+
"Let me think step by step about this problem.",
658+
);
659+
});
660+
661+
it("no reasoning_content when reasoning is absent", async () => {
662+
instance = await createServer(allFixtures);
663+
const res = await post(`${instance.url}/v1/chat/completions`, {
664+
model: "gpt-4",
665+
messages: [{ role: "user", content: "plain" }],
666+
stream: false,
667+
});
668+
669+
const body = JSON.parse(res.body);
670+
expect(body.choices[0].message.content).toBe("Just plain text.");
671+
expect(body.choices[0].message.reasoning_content).toBeUndefined();
672+
});
673+
});

src/__tests__/stream-collapse.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,3 +1792,35 @@ describe("collapseAnthropicSSE with thinking", () => {
17921792
expect(result.reasoning).toBeUndefined();
17931793
});
17941794
});
1795+
1796+
describe("collapseOpenAISSE with chat completions reasoning_content", () => {
1797+
it("extracts reasoning from reasoning_content delta fields", () => {
1798+
const body = [
1799+
`data: ${JSON.stringify({ id: "chatcmpl-1", choices: [{ delta: { reasoning_content: "Let me " } }] })}`,
1800+
"",
1801+
`data: ${JSON.stringify({ id: "chatcmpl-1", choices: [{ delta: { reasoning_content: "think." } }] })}`,
1802+
"",
1803+
`data: ${JSON.stringify({ id: "chatcmpl-1", choices: [{ delta: { content: "Answer" } }] })}`,
1804+
"",
1805+
"data: [DONE]",
1806+
"",
1807+
].join("\n");
1808+
1809+
const result = collapseOpenAISSE(body);
1810+
expect(result.content).toBe("Answer");
1811+
expect(result.reasoning).toBe("Let me think.");
1812+
});
1813+
1814+
it("handles reasoning_content without regular content", () => {
1815+
const body = [
1816+
`data: ${JSON.stringify({ id: "chatcmpl-2", choices: [{ delta: { reasoning_content: "Thinking only" } }] })}`,
1817+
"",
1818+
"data: [DONE]",
1819+
"",
1820+
].join("\n");
1821+
1822+
const result = collapseOpenAISSE(body);
1823+
expect(result.reasoning).toBe("Thinking only");
1824+
expect(result.content).toBe("");
1825+
});
1826+
});

src/helpers.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,30 @@ export function isEmbeddingResponse(r: FixtureResponse): r is EmbeddingResponse
6262
return "embedding" in r && Array.isArray((r as EmbeddingResponse).embedding);
6363
}
6464

65-
export function buildTextChunks(content: string, model: string, chunkSize: number): SSEChunk[] {
65+
export function buildTextChunks(
66+
content: string,
67+
model: string,
68+
chunkSize: number,
69+
reasoning?: string,
70+
): SSEChunk[] {
6671
const id = generateId();
6772
const created = Math.floor(Date.now() / 1000);
6873
const chunks: SSEChunk[] = [];
6974

75+
// Reasoning chunks (emitted before content, OpenRouter format)
76+
if (reasoning) {
77+
for (let i = 0; i < reasoning.length; i += chunkSize) {
78+
const slice = reasoning.slice(i, i + chunkSize);
79+
chunks.push({
80+
id,
81+
object: "chat.completion.chunk",
82+
created,
83+
model,
84+
choices: [{ index: 0, delta: { reasoning_content: slice }, finish_reason: null }],
85+
});
86+
}
87+
}
88+
7089
// Role chunk
7190
chunks.push({
7291
id,
@@ -183,7 +202,11 @@ export function buildToolCallChunks(
183202

184203
// Non-streaming response builders
185204

186-
export function buildTextCompletion(content: string, model: string): ChatCompletion {
205+
export function buildTextCompletion(
206+
content: string,
207+
model: string,
208+
reasoning?: string,
209+
): ChatCompletion {
187210
return {
188211
id: generateId(),
189212
object: "chat.completion",
@@ -192,7 +215,12 @@ export function buildTextCompletion(content: string, model: string): ChatComplet
192215
choices: [
193216
{
194217
index: 0,
195-
message: { role: "assistant", content, refusal: null },
218+
message: {
219+
role: "assistant",
220+
content,
221+
refusal: null,
222+
...(reasoning ? { reasoning_content: reasoning } : {}),
223+
},
196224
finish_reason: "stop",
197225
},
198226
],

src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,11 @@ async function handleCompletions(
454454
response: { status: 200, fixture },
455455
});
456456
if (body.stream !== true) {
457-
const completion = buildTextCompletion(response.content, body.model);
457+
const completion = buildTextCompletion(response.content, body.model, response.reasoning);
458458
res.writeHead(200, { "Content-Type": "application/json" });
459459
res.end(JSON.stringify(completion));
460460
} else {
461-
const chunks = buildTextChunks(response.content, body.model, chunkSize);
461+
const chunks = buildTextChunks(response.content, body.model, chunkSize, response.reasoning);
462462
const interruption = createInterruptionSignal(fixture);
463463
const completed = await writeSSEStream(res, chunks, {
464464
latency,

src/stream-collapse.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ export function collapseOpenAISSE(body: string): CollapseResult {
9595
const delta = choices[0].delta as Record<string, unknown> | undefined;
9696
if (!delta) continue;
9797

98+
// Reasoning content (OpenRouter / chat completions format)
99+
if (typeof delta.reasoning_content === "string") {
100+
reasoning += delta.reasoning_content;
101+
}
102+
98103
// Text content
99104
if (typeof delta.content === "string") {
100105
content += delta.content;

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export interface SSEChoice {
200200
export interface SSEDelta {
201201
role?: string;
202202
content?: string | null;
203+
reasoning_content?: string;
203204
tool_calls?: SSEToolCallDelta[];
204205
}
205206

@@ -231,6 +232,7 @@ export interface ChatCompletionMessage {
231232
role: "assistant";
232233
content: string | null;
233234
refusal: string | null;
235+
reasoning_content?: string;
234236
tool_calls?: ToolCallMessage[];
235237
}
236238

0 commit comments

Comments
 (0)