Skip to content

Commit fd4bf4a

Browse files
authored
feat: add reasoning_content support for OpenRouter chat completions (#88)
## Summary - Adds `reasoning_content` field to chat completions streaming (`delta.reasoning_content`) and non-streaming (`message.reasoning_content`) responses - Updates `collapseOpenAISSE` to extract `reasoning_content` from chat completions delta chunks for record-and-replay - OpenRouter models that support reasoning (DeepSeek, Claude via OpenRouter, etc.) return thinking tokens via this field — aimock previously only supported reasoning for the Responses API and Anthropic Messages API ## What's included This PR covers the **aimock mock server** side — emitting `reasoning_content` in `/v1/chat/completions` responses when a fixture has `reasoning` set. It does NOT cover any additional OpenRouter-specific behavior (e.g. `reasoning_details`, model routing, provider preferences). Those may need separate work. ## Files changed - `src/types.ts` — added `reasoning_content` to `SSEDelta` and `ChatCompletionMessage` - `src/helpers.ts` — `buildTextChunks` and `buildTextCompletion` now accept and emit reasoning - `src/server.ts` — passes `response.reasoning` through to chat completions builders - `src/stream-collapse.ts` — extracts `reasoning_content` from chat completions deltas - Tests — 5 new tests covering streaming, non-streaming, absence, delta reconstruction, and stream-collapse ## Test plan - [x] Streaming: reasoning_content deltas emitted before content deltas - [x] Streaming: reasoning_content deltas reconstruct full reasoning text - [x] Streaming: content deltas still reconstruct full text - [x] Streaming: no reasoning_content when fixture has no reasoning - [x] Non-streaming: reasoning_content present in message - [x] Non-streaming: reasoning_content absent when no reasoning - [x] Stream-collapse: extracts reasoning_content from chat completions deltas - [x] All 2034 existing tests still pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents c559111 + 66e61be commit fd4bf4a

File tree

4 files changed

+169
-10
lines changed

4 files changed

+169
-10
lines changed

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: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,7 @@ export function buildTextChunks(
7272
const created = Math.floor(Date.now() / 1000);
7373
const chunks: SSEChunk[] = [];
7474

75-
// Role chunk
76-
chunks.push({
77-
id,
78-
object: "chat.completion.chunk",
79-
created,
80-
model,
81-
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
82-
});
83-
84-
// Reasoning chunks (emitted before content chunks)
75+
// Reasoning chunks (emitted before content, OpenRouter format)
8576
if (reasoning) {
8677
for (let i = 0; i < reasoning.length; i += chunkSize) {
8778
const slice = reasoning.slice(i, i + chunkSize);
@@ -95,6 +86,15 @@ export function buildTextChunks(
9586
}
9687
}
9788

89+
// Role chunk
90+
chunks.push({
91+
id,
92+
object: "chat.completion.chunk",
93+
created,
94+
model,
95+
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
96+
});
97+
9898
// Content chunks
9999
for (let i = 0; i < content.length; i += chunkSize) {
100100
const slice = content.slice(i, i + chunkSize);

src/stream-collapse.ts

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

101+
// Reasoning content (OpenRouter / chat completions format)
102+
if (typeof delta.reasoning_content === "string") {
103+
reasoning += delta.reasoning_content;
104+
}
105+
101106
// Text content
102107
if (typeof delta.content === "string") {
103108
content += delta.content;

0 commit comments

Comments
 (0)