Skip to content
Merged
786 changes: 786 additions & 0 deletions src/__tests__/reasoning-all-providers.test.ts

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions src/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,20 @@ export function converseToCompletionRequest(

// ─── Response builders ──────────────────────────────────────────────────────

function buildConverseTextResponse(content: string): object {
function buildConverseTextResponse(content: string, reasoning?: string): object {
const contentBlocks: object[] = [];
if (reasoning) {
contentBlocks.push({
reasoningContent: { reasoningText: { text: reasoning } },
});
}
contentBlocks.push({ text: content });

return {
output: {
message: {
role: "assistant",
content: [{ text: content }],
content: contentBlocks,
},
},
stopReason: "end_turn",
Expand Down Expand Up @@ -368,7 +376,7 @@ export async function handleConverse(
body: completionReq,
response: { status: 200, fixture },
});
const body = buildConverseTextResponse(response.content);
const body = buildConverseTextResponse(response.content, response.reasoning);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
return;
Expand Down Expand Up @@ -578,7 +586,7 @@ export async function handleConverseStream(
body: completionReq,
response: { status: 200, fixture },
});
const events = buildBedrockStreamTextEvents(response.content, chunkSize);
const events = buildBedrockStreamTextEvents(response.content, chunkSize, response.reasoning);
const interruption = createInterruptionSignal(fixture);
const completed = await writeEventStream(res, events, {
latency,
Expand Down
53 changes: 46 additions & 7 deletions src/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,18 @@ export function bedrockToCompletionRequest(

// ─── Response builders ──────────────────────────────────────────────────────

function buildBedrockTextResponse(content: string, model: string): object {
function buildBedrockTextResponse(content: string, model: string, reasoning?: string): object {
const contentBlocks: object[] = [];
if (reasoning) {
contentBlocks.push({ type: "thinking", thinking: reasoning });
}
contentBlocks.push({ type: "text", text: content });

return {
id: generateMessageId(),
type: "message",
role: "assistant",
content: [{ type: "text", text: content }],
content: contentBlocks,
model,
stop_reason: "end_turn",
stop_sequence: null,
Expand Down Expand Up @@ -422,7 +428,11 @@ export async function handleBedrock(
body: completionReq,
response: { status: 200, fixture },
});
const body = buildBedrockTextResponse(response.content, completionReq.model);
const body = buildBedrockTextResponse(
response.content,
completionReq.model,
response.reasoning,
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
return;
Expand Down Expand Up @@ -468,6 +478,7 @@ export async function handleBedrock(
export function buildBedrockStreamTextEvents(
content: string,
chunkSize: number,
reasoning?: string,
): Array<{ eventType: string; payload: object }> {
const events: Array<{ eventType: string; payload: object }> = [];

Expand All @@ -476,25 +487,53 @@ export function buildBedrockStreamTextEvents(
payload: { role: "assistant" },
});

// Thinking block (emitted before text when reasoning is present)
if (reasoning) {
const blockIndex = 0;
events.push({
eventType: "contentBlockStart",
payload: { contentBlockIndex: blockIndex, start: { type: "thinking" } },
});

for (let i = 0; i < reasoning.length; i += chunkSize) {
const slice = reasoning.slice(i, i + chunkSize);
events.push({
eventType: "contentBlockDelta",
payload: {
contentBlockIndex: blockIndex,
delta: { type: "thinking_delta", thinking: slice },
},
});
}

events.push({
eventType: "contentBlockStop",
payload: { contentBlockIndex: blockIndex },
});
}

// Text block
const textBlockIndex = reasoning ? 1 : 0;

events.push({
eventType: "contentBlockStart",
payload: { contentBlockIndex: 0, start: {} },
payload: { contentBlockIndex: textBlockIndex, start: {} },
});

for (let i = 0; i < content.length; i += chunkSize) {
const slice = content.slice(i, i + chunkSize);
events.push({
eventType: "contentBlockDelta",
payload: {
contentBlockIndex: 0,
contentBlockIndex: textBlockIndex,
delta: { type: "text_delta", text: slice },
},
});
}

events.push({
eventType: "contentBlockStop",
payload: { contentBlockIndex: 0 },
payload: { contentBlockIndex: textBlockIndex },
});

events.push({
Expand Down Expand Up @@ -738,7 +777,7 @@ export async function handleBedrockStream(
body: completionReq,
response: { status: 200, fixture },
});
const events = buildBedrockStreamTextEvents(response.content, chunkSize);
const events = buildBedrockStreamTextEvents(response.content, chunkSize, response.reasoning);
const interruption = createInterruptionSignal(fixture);
const completed = await writeEventStream(res, events, {
latency,
Expand Down
40 changes: 33 additions & 7 deletions src/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { proxyAndRecord } from "./recorder.js";

interface GeminiPart {
text?: string;
thought?: boolean;
functionCall?: { name: string; args: Record<string, unknown>; id?: string };
functionResponse?: { name: string; response: unknown };
}
Expand Down Expand Up @@ -187,10 +188,29 @@ interface GeminiResponseChunk {
};
}

function buildGeminiTextStreamChunks(content: string, chunkSize: number): GeminiResponseChunk[] {
function buildGeminiTextStreamChunks(
content: string,
chunkSize: number,
reasoning?: string,
): GeminiResponseChunk[] {
const chunks: GeminiResponseChunk[] = [];

// Content chunks
// Reasoning chunks (thought: true)
if (reasoning) {
for (let i = 0; i < reasoning.length; i += chunkSize) {
const slice = reasoning.slice(i, i + chunkSize);
chunks.push({
candidates: [
{
content: { role: "model", parts: [{ text: slice, thought: true }] },
index: 0,
},
],
});
}
}

// Content chunks (original logic unchanged)
for (let i = 0; i < content.length; i += chunkSize) {
const slice = content.slice(i, i + chunkSize);
const isLast = i + chunkSize >= content.length;
Expand All @@ -215,7 +235,7 @@ function buildGeminiTextStreamChunks(content: string, chunkSize: number): Gemini
chunks.push(chunk);
}

// Handle empty content
// Handle empty content (original logic unchanged)
if (content.length === 0) {
chunks.push({
candidates: [
Expand Down Expand Up @@ -276,11 +296,17 @@ function buildGeminiToolCallStreamChunks(

// Non-streaming response builders

function buildGeminiTextResponse(content: string): GeminiResponseChunk {
function buildGeminiTextResponse(content: string, reasoning?: string): GeminiResponseChunk {
const parts: GeminiPart[] = [];
if (reasoning) {
parts.push({ text: reasoning, thought: true });
}
parts.push({ text: content });

return {
candidates: [
{
content: { role: "model", parts: [{ text: content }] },
content: { role: "model", parts },
finishReason: "STOP",
index: 0,
},
Expand Down Expand Up @@ -533,11 +559,11 @@ export async function handleGemini(
response: { status: 200, fixture },
});
if (!streaming) {
const body = buildGeminiTextResponse(response.content);
const body = buildGeminiTextResponse(response.content, response.reasoning);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
} else {
const chunks = buildGeminiTextStreamChunks(response.content, chunkSize);
const chunks = buildGeminiTextStreamChunks(response.content, chunkSize, response.reasoning);
const interruption = createInterruptionSignal(fixture);
const completed = await writeGeminiSSEStream(res, chunks, {
latency,
Expand Down
34 changes: 31 additions & 3 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ export function isEmbeddingResponse(r: FixtureResponse): r is EmbeddingResponse
return "embedding" in r && Array.isArray((r as EmbeddingResponse).embedding);
}

export function buildTextChunks(content: string, model: string, chunkSize: number): SSEChunk[] {
export function buildTextChunks(
content: string,
model: string,
chunkSize: number,
reasoning?: string,
): SSEChunk[] {
const id = generateId();
const created = Math.floor(Date.now() / 1000);
const chunks: SSEChunk[] = [];
Expand All @@ -76,6 +81,20 @@ export function buildTextChunks(content: string, model: string, chunkSize: numbe
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
});

// Reasoning chunks (emitted before content chunks)
if (reasoning) {
for (let i = 0; i < reasoning.length; i += chunkSize) {
const slice = reasoning.slice(i, i + chunkSize);
chunks.push({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { reasoning_content: slice }, finish_reason: null }],
});
}
}

// Content chunks
for (let i = 0; i < content.length; i += chunkSize) {
const slice = content.slice(i, i + chunkSize);
Expand Down Expand Up @@ -183,7 +202,11 @@ export function buildToolCallChunks(

// Non-streaming response builders

export function buildTextCompletion(content: string, model: string): ChatCompletion {
export function buildTextCompletion(
content: string,
model: string,
reasoning?: string,
): ChatCompletion {
return {
id: generateId(),
object: "chat.completion",
Expand All @@ -192,7 +215,12 @@ export function buildTextCompletion(content: string, model: string): ChatComplet
choices: [
{
index: 0,
message: { role: "assistant", content, refusal: null },
message: {
role: "assistant",
content,
refusal: null,
...(reasoning ? { reasoning_content: reasoning } : {}),
},
finish_reason: "stop",
},
],
Expand Down
Loading
Loading