diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bed2034..522fe1c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "aimock", - "version": "1.19.1", + "version": "1.19.2", "description": "Fixture authoring guidance for @copilotkit/aimock — LLM, multimedia, MCP, A2A, AG-UI, vector, and service mocking", "author": { "name": "CopilotKit" diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbd019..f992bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # @copilotkit/aimock +## [1.19.2] - 2026-05-07 + +### Fixed + +- **Converse stream: double-wrapped Event Stream payloads** — `buildBedrockStreamTextEvents`, `buildBedrockStreamToolCallEvents`, and `buildBedrockStreamContentWithToolCallsEvents` emitted payloads wrapped with the event type name (e.g. `{ messageStart: { role: "assistant" } }`). The `:event-type` header already carries the event name, so AWS SDK (botocore) expected flat payloads (e.g. `{ role: "assistant" }`). The redundant wrapping caused botocore's `BaseEventStreamParser` to silently return empty dicts, producing `KeyError: 'role'` in downstream frameworks like Strands Agents. (Issue #162, reported by @KMiya84377) +- **Responses API: missing item_id on 3 SSE event types** — Added `item_id` to `response.output_text.done`, `response.content_part.added`, and `response.content_part.done` events, matching the real OpenAI Responses API shape. SDK drift shapes updated. +- **Chat Completions: missing logprobs on choices** — Added `logprobs: null` to all streaming chunks and non-streaming choices. Removed `logprobs` from drift allowlist so future omissions are caught. +- **Ollama: missing created_at on /api/chat** — Added `created_at` to all 6 `/api/chat` builder functions (text, tool call, content+tools, and their streaming variants). The `/api/generate` path already had it. +- **Gemini: error fixtures used Anthropic-style error codes** — Test fixtures and the Gemini Live WebSocket handler now use Google canonical gRPC status codes (`RESOURCE_EXHAUSTED`, `INTERNAL`) instead of `rate_limit_error` / `ERROR`. + ## [1.19.1] - 2026-05-07 ### Fixed diff --git a/charts/aimock/Chart.yaml b/charts/aimock/Chart.yaml index a793817..35aac6b 100644 --- a/charts/aimock/Chart.yaml +++ b/charts/aimock/Chart.yaml @@ -3,4 +3,4 @@ name: aimock description: Mock infrastructure for AI application testing (OpenAI, Anthropic, Gemini, MCP, A2A, vector) type: application version: 0.1.0 -appVersion: "1.19.1" +appVersion: "1.19.2" diff --git a/package.json b/package.json index 808a269..44f531a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/aimock", - "version": "1.19.1", + "version": "1.19.2", "description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, audio generation, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.", "license": "MIT", "keywords": [ diff --git a/src/__tests__/api-conformance.test.ts b/src/__tests__/api-conformance.test.ts index 944bb1a..479dd27 100644 --- a/src/__tests__/api-conformance.test.ts +++ b/src/__tests__/api-conformance.test.ts @@ -182,7 +182,7 @@ describe("OpenAI Chat Completions conformance", () => { expect(typeof json.created).toBe("number"); }); - it("choices[0] has index, message, and finish_reason", async () => { + it("choices[0] has index, message, logprobs, and finish_reason", async () => { const res = await httpPost(chatPath(), { model: "gpt-4", messages: [{ role: "user", content: "hello" }], @@ -192,6 +192,8 @@ describe("OpenAI Chat Completions conformance", () => { const choice = json.choices[0]; expect(choice).toHaveProperty("index"); expect(choice).toHaveProperty("message"); + expect(choice).toHaveProperty("logprobs"); + expect(choice.logprobs).toBeNull(); expect(choice).toHaveProperty("finish_reason"); expect(choice.message.role).toBe("assistant"); expect(typeof choice.message.content).toBe("string"); @@ -276,7 +278,7 @@ describe("OpenAI Chat Completions conformance", () => { expect(res.body.trimEnd()).toMatch(/data: \[DONE\]$/); }); - it("each chunk has id, object chat.completion.chunk, created, model, choices", async () => { + it("each chunk has id, object chat.completion.chunk, created, model, choices with logprobs", async () => { const res = await httpPost(chatPath(), { model: "gpt-4", messages: [{ role: "user", content: "hello" }], @@ -291,6 +293,8 @@ describe("OpenAI Chat Completions conformance", () => { expect(c).toHaveProperty("created"); expect(c).toHaveProperty("model"); expect(c).toHaveProperty("choices"); + expect(c.choices[0]).toHaveProperty("logprobs"); + expect(c.choices[0].logprobs).toBeNull(); } }); diff --git a/src/__tests__/bedrock-stream.test.ts b/src/__tests__/bedrock-stream.test.ts index 57e541b..caf09ef 100644 --- a/src/__tests__/bedrock-stream.test.ts +++ b/src/__tests__/bedrock-stream.test.ts @@ -765,24 +765,21 @@ describe("POST /model/{modelId}/converse-stream", () => { const frames = parseFrames(res.body); - // Verify event sequence + // Verify event sequence — payloads are NOT wrapped with the event type name; + // the :event-type header already carries that information. expect(frames[0].eventType).toBe("messageStart"); - expect(frames[0].payload).toEqual({ messageStart: { role: "assistant" } }); + expect(frames[0].payload).toEqual({ role: "assistant" }); expect(frames[1].eventType).toBe("contentBlockStart"); const deltas = frames.filter((f) => f.eventType === "contentBlockDelta"); const fullText = deltas - .map( - (f) => - (f.payload as { contentBlockDelta: { delta: { text: string } } }).contentBlockDelta.delta - .text, - ) + .map((f) => (f.payload as { delta: { text: string } }).delta.text) .join(""); expect(fullText).toBe("Hi there!"); const msgStop = frames.find((f) => f.eventType === "messageStop"); - expect(msgStop!.payload).toEqual({ messageStop: { stopReason: "end_turn" } }); + expect(msgStop!.payload).toEqual({ stopReason: "end_turn" }); }); it("returns tool call response as Event Stream", async () => { @@ -795,29 +792,23 @@ describe("POST /model/{modelId}/converse-stream", () => { const frames = parseFrames(res.body); expect(frames[0].eventType).toBe("messageStart"); + expect(frames[0].payload).toEqual({ role: "assistant" }); const startFrame = frames.find((f) => f.eventType === "contentBlockStart"); const startPayload = startFrame!.payload as { contentBlockIndex: number; - contentBlockStart: { - contentBlockIndex: number; - start: { toolUse: { toolUseId: string; name: string } }; - }; + start: { toolUse: { toolUseId: string; name: string } }; }; - expect(startPayload.contentBlockStart.start.toolUse.name).toBe("get_weather"); + expect(startPayload.start.toolUse.name).toBe("get_weather"); const deltas = frames.filter((f) => f.eventType === "contentBlockDelta"); const fullJson = deltas - .map( - (f) => - (f.payload as { contentBlockDelta: { delta: { toolUse: { input: string } } } }) - .contentBlockDelta.delta.toolUse.input, - ) + .map((f) => (f.payload as { delta: { toolUse: { input: string } } }).delta.toolUse.input) .join(""); expect(JSON.parse(fullJson)).toEqual({ city: "SF" }); const msgStop = frames.find((f) => f.eventType === "messageStop"); - expect(msgStop!.payload).toEqual({ messageStop: { stopReason: "tool_use" } }); + expect(msgStop!.payload).toEqual({ stopReason: "tool_use" }); }); it("supports streaming profile (ttft/tps)", async () => { @@ -886,16 +877,15 @@ describe("POST /model/{modelId}/converse-stream (content + toolCalls)", () => { const frames = parseFrames(res.body); - // messageStart + // messageStart — payload is flat (not wrapped with event type name) expect(frames[0].eventType).toBe("messageStart"); - expect(frames[0].payload).toEqual({ messageStart: { role: "assistant" } }); + expect(frames[0].payload).toEqual({ role: "assistant" }); // Text contentBlockStart const textBlockStart = frames.find( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { type?: string } } }).contentBlockStart - ?.start?.type === "text", + (f.payload as { start?: { type?: string } }).start?.type === "text", ); expect(textBlockStart).toBeDefined(); @@ -903,16 +893,11 @@ describe("POST /model/{modelId}/converse-stream (content + toolCalls)", () => { const textDeltas = frames.filter( (f) => f.eventType === "contentBlockDelta" && - (f.payload as { contentBlockDelta?: { delta?: { text?: string } } }).contentBlockDelta - ?.delta?.text !== undefined, + (f.payload as { delta?: { text?: string } }).delta?.text !== undefined, ); expect(textDeltas.length).toBeGreaterThanOrEqual(1); const fullText = textDeltas - .map( - (f) => - (f.payload as { contentBlockDelta: { delta: { text: string } } }).contentBlockDelta.delta - .text, - ) + .map((f) => (f.payload as { delta: { text: string } }).delta.text) .join(""); expect(fullText).toBe("Let me look that up."); @@ -920,49 +905,40 @@ describe("POST /model/{modelId}/converse-stream (content + toolCalls)", () => { const toolBlockStart = frames.find( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { toolUse?: unknown } } }).contentBlockStart - ?.start?.toolUse !== undefined, + (f.payload as { start?: { toolUse?: unknown } }).start?.toolUse !== undefined, ); expect(toolBlockStart).toBeDefined(); const toolStartPayload = toolBlockStart!.payload as { contentBlockIndex: number; - contentBlockStart: { - contentBlockIndex: number; - start: { toolUse: { toolUseId: string; name: string } }; - }; + start: { toolUse: { toolUseId: string; name: string } }; }; - expect(toolStartPayload.contentBlockStart.start.toolUse.name).toBe("web_search"); - expect(toolStartPayload.contentBlockStart.start.toolUse.toolUseId).toBeDefined(); + expect(toolStartPayload.start.toolUse.name).toBe("web_search"); + expect(toolStartPayload.start.toolUse.toolUseId).toBeDefined(); // Tool deltas — toolUse.input const toolDeltas = frames.filter( (f) => f.eventType === "contentBlockDelta" && - (f.payload as { contentBlockDelta?: { delta?: { toolUse?: unknown } } }).contentBlockDelta - ?.delta?.toolUse !== undefined, + (f.payload as { delta?: { toolUse?: unknown } }).delta?.toolUse !== undefined, ); expect(toolDeltas.length).toBeGreaterThanOrEqual(1); const fullJson = toolDeltas - .map( - (f) => - (f.payload as { contentBlockDelta: { delta: { toolUse: { input: string } } } }) - .contentBlockDelta.delta.toolUse.input, - ) + .map((f) => (f.payload as { delta: { toolUse: { input: string } } }).delta.toolUse.input) .join(""); expect(JSON.parse(fullJson)).toEqual({ query: "vitest testing" }); // messageStop with tool_use stop reason const msgStop = frames.find((f) => f.eventType === "messageStop"); - expect(msgStop!.payload).toEqual({ messageStop: { stopReason: "tool_use" } }); + expect(msgStop!.payload).toEqual({ stopReason: "tool_use" }); }); }); -// ─── converse-stream: contentBlockStop wrapper shape ────────────────────────── +// ─── converse-stream: payload shape (not double-wrapped) ────────────────────── -describe("POST /model/{modelId}/converse-stream (contentBlockStop wrapper shape)", () => { +describe("POST /model/{modelId}/converse-stream (payload shape)", () => { const MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; - it("contentBlockStop events have wrapped { contentBlockStop: { contentBlockIndex: N } } shape", async () => { + it("contentBlockStop payloads are flat { contentBlockIndex: N } (not double-wrapped)", async () => { instance = await createServer(allFixtures); const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, { messages: [{ role: "user", content: [{ text: "hello" }] }], @@ -975,17 +951,15 @@ describe("POST /model/{modelId}/converse-stream (contentBlockStop wrapper shape) expect(stopFrames.length).toBeGreaterThanOrEqual(1); for (const frame of stopFrames) { - // Must be the wrapped shape, not the flat { contentBlockIndex: N } - const payload = frame.payload as { contentBlockStop: { contentBlockIndex: number } }; - expect(payload).toHaveProperty("contentBlockStop"); - expect(payload.contentBlockStop).toHaveProperty("contentBlockIndex"); - expect(typeof payload.contentBlockStop.contentBlockIndex).toBe("number"); - // Must NOT have a top-level contentBlockIndex (that would be the flat shape) - expect(Object.keys(payload)).toEqual(["contentBlockStop"]); + const payload = frame.payload as { contentBlockIndex: number }; + expect(payload).toHaveProperty("contentBlockIndex"); + expect(typeof payload.contentBlockIndex).toBe("number"); + // Must NOT be double-wrapped with the event type name + expect(payload).not.toHaveProperty("contentBlockStop"); } }); - it("tool-call contentBlockStop events also have the wrapped shape", async () => { + it("tool-call contentBlockStop payloads are also flat", async () => { instance = await createServer(allFixtures); const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, { messages: [{ role: "user", content: [{ text: "weather" }] }], @@ -998,14 +972,13 @@ describe("POST /model/{modelId}/converse-stream (contentBlockStop wrapper shape) expect(stopFrames.length).toBeGreaterThanOrEqual(1); for (const frame of stopFrames) { - const payload = frame.payload as { contentBlockStop: { contentBlockIndex: number } }; - expect(payload).toHaveProperty("contentBlockStop"); - expect(payload.contentBlockStop).toHaveProperty("contentBlockIndex"); - expect(Object.keys(payload)).toEqual(["contentBlockStop"]); + const payload = frame.payload as { contentBlockIndex: number }; + expect(payload).toHaveProperty("contentBlockIndex"); + expect(payload).not.toHaveProperty("contentBlockStop"); } }); - it("messageStop events have the wrapped { messageStop: { stopReason: '...' } } shape", async () => { + it("messageStop payloads are flat { stopReason: '...' } (not double-wrapped)", async () => { instance = await createServer(allFixtures); const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, { messages: [{ role: "user", content: [{ text: "hello" }] }], @@ -1017,10 +990,45 @@ describe("POST /model/{modelId}/converse-stream (contentBlockStop wrapper shape) const msgStopFrames = frames.filter((f) => f.eventType === "messageStop"); expect(msgStopFrames).toHaveLength(1); - const payload = msgStopFrames[0].payload as { messageStop: { stopReason: string } }; - expect(payload).toHaveProperty("messageStop"); - expect(payload.messageStop).toHaveProperty("stopReason"); - expect(Object.keys(payload)).toEqual(["messageStop"]); + const payload = msgStopFrames[0].payload as { stopReason: string }; + expect(payload).toHaveProperty("stopReason"); + expect(payload).not.toHaveProperty("messageStop"); + expect(Object.keys(payload)).toEqual(["stopReason"]); + }); + + it("messageStart payloads are flat { role: 'assistant' } (not double-wrapped)", async () => { + instance = await createServer(allFixtures); + const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, { + messages: [{ role: "user", content: [{ text: "hello" }] }], + }); + + expect(res.status).toBe(200); + const frames = parseFrames(res.body); + + const msgStartFrames = frames.filter((f) => f.eventType === "messageStart"); + expect(msgStartFrames).toHaveLength(1); + + const payload = msgStartFrames[0].payload as { role: string }; + expect(payload).toEqual({ role: "assistant" }); + expect(payload).not.toHaveProperty("messageStart"); + }); + + it("metadata payloads are flat { usage: ..., metrics: ... } (not double-wrapped)", async () => { + instance = await createServer(allFixtures); + const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, { + messages: [{ role: "user", content: [{ text: "hello" }] }], + }); + + expect(res.status).toBe(200); + const frames = parseFrames(res.body); + + const metadataFrames = frames.filter((f) => f.eventType === "metadata"); + expect(metadataFrames).toHaveLength(1); + + const payload = metadataFrames[0].payload as { usage: object; metrics: object }; + expect(payload).toHaveProperty("usage"); + expect(payload).toHaveProperty("metrics"); + expect(payload).not.toHaveProperty("metadata"); }); }); @@ -1038,9 +1046,9 @@ describe("POST /model/{modelId}/converse-stream (contentWithToolCalls full struc expect(res.status).toBe(200); const frames = parseFrames(res.body); - // 1. Stream starts with messageStart (role: assistant) + // 1. Stream starts with messageStart (role: assistant) — flat payload expect(frames[0].eventType).toBe("messageStart"); - expect(frames[0].payload).toEqual({ messageStart: { role: "assistant" } }); + expect(frames[0].payload).toEqual({ role: "assistant" }); // 2. Collect all contentBlockStart frames const blockStarts = frames.filter((f) => f.eventType === "contentBlockStart"); @@ -1050,50 +1058,42 @@ describe("POST /model/{modelId}/converse-stream (contentWithToolCalls full struc const textBlockStartIdx = frames.findIndex( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { type?: string } } }).contentBlockStart - ?.start?.type === "text", + (f.payload as { start?: { type?: string } }).start?.type === "text", ); const toolBlockStartIdx = frames.findIndex( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { toolUse?: unknown } } }).contentBlockStart - ?.start?.toolUse !== undefined, + (f.payload as { start?: { toolUse?: unknown } }).start?.toolUse !== undefined, ); expect(textBlockStartIdx).toBeLessThan(toolBlockStartIdx); - // 4. Tool call block has contentBlockStart with toolUse (toolUseId + name) + // 4. Tool call block has contentBlockStart with toolUse (toolUseId + name) — flat payload const toolBlockStart = frames[toolBlockStartIdx]; const toolStartPayload = toolBlockStart.payload as { - contentBlockStart: { - contentBlockIndex: number; - start: { toolUse: { toolUseId: string; name: string } }; - }; + contentBlockIndex: number; + start: { toolUse: { toolUseId: string; name: string } }; }; - expect(toolStartPayload.contentBlockStart.start.toolUse.name).toBe("web_search"); - expect(toolStartPayload.contentBlockStart.start.toolUse.toolUseId).toBeDefined(); - expect(typeof toolStartPayload.contentBlockStart.start.toolUse.toolUseId).toBe("string"); + expect(toolStartPayload.start.toolUse.name).toBe("web_search"); + expect(toolStartPayload.start.toolUse.toolUseId).toBeDefined(); + expect(typeof toolStartPayload.start.toolUse.toolUseId).toBe("string"); // 5. Tool call block has contentBlockDelta chunks after its start - const toolBlockIndex = toolStartPayload.contentBlockStart.contentBlockIndex; + const toolBlockIndex = toolStartPayload.contentBlockIndex; const toolDeltas = frames.filter( (f) => f.eventType === "contentBlockDelta" && - (f.payload as { contentBlockDelta?: { contentBlockIndex?: number } }).contentBlockDelta - ?.contentBlockIndex === toolBlockIndex, + (f.payload as { contentBlockIndex?: number }).contentBlockIndex === toolBlockIndex, ); expect(toolDeltas.length).toBeGreaterThanOrEqual(1); - // 6. Tool call block has contentBlockStop + // 6. Tool call block has contentBlockStop — flat payload const toolBlockStop = frames.find( (f) => f.eventType === "contentBlockStop" && - (f.payload as { contentBlockStop?: { contentBlockIndex?: number } }).contentBlockStop - ?.contentBlockIndex === toolBlockIndex, + (f.payload as { contentBlockIndex?: number }).contentBlockIndex === toolBlockIndex, ); expect(toolBlockStop).toBeDefined(); - expect(toolBlockStop!.payload).toEqual({ - contentBlockStop: { contentBlockIndex: toolBlockIndex }, - }); + expect(toolBlockStop!.payload).toEqual({ contentBlockIndex: toolBlockIndex }); // 7. Stream ends with messageStop (stopReason: tool_use) then metadata const msgStopIdx = frames.findIndex((f) => f.eventType === "messageStop"); @@ -1103,28 +1103,18 @@ describe("POST /model/{modelId}/converse-stream (contentWithToolCalls full struc expect(metadataIdx).toBe(msgStopIdx + 1); // metadata immediately follows messageStop expect(metadataIdx).toBe(frames.length - 1); // metadata is last frame - const msgStopPayload = frames[msgStopIdx].payload as { - messageStop: { stopReason: string }; - }; - expect(msgStopPayload).toEqual({ messageStop: { stopReason: "tool_use" } }); + const msgStopPayload = frames[msgStopIdx].payload as { stopReason: string }; + expect(msgStopPayload).toEqual({ stopReason: "tool_use" }); - // 8. contentBlockIndex values are sequential across text and tool blocks + // 8. contentBlockIndex values are sequential across text and tool blocks — flat payloads const allBlockStarts = frames .filter((f) => f.eventType === "contentBlockStart") - .map( - (f) => - (f.payload as { contentBlockStart: { contentBlockIndex: number } }).contentBlockStart - .contentBlockIndex, - ); + .map((f) => (f.payload as { contentBlockIndex: number }).contentBlockIndex); expect(allBlockStarts).toEqual([0, 1]); const allBlockStops = frames .filter((f) => f.eventType === "contentBlockStop") - .map( - (f) => - (f.payload as { contentBlockStop: { contentBlockIndex: number } }).contentBlockStop - .contentBlockIndex, - ); + .map((f) => (f.payload as { contentBlockIndex: number }).contentBlockIndex); expect(allBlockStops).toEqual([0, 1]); }); @@ -1147,46 +1137,40 @@ describe("POST /model/{modelId}/converse-stream (contentWithToolCalls full struc expect(res.status).toBe(200); const frames = parseFrames(res.body); - // contentBlockIndex: 0 = text, 1 = tool_a, 2 = tool_b + // contentBlockIndex: 0 = text, 1 = tool_a, 2 = tool_b — flat payloads const blockStarts = frames.filter((f) => f.eventType === "contentBlockStart"); expect(blockStarts).toHaveLength(3); const indices = blockStarts.map( - (f) => - (f.payload as { contentBlockStart: { contentBlockIndex: number } }).contentBlockStart - .contentBlockIndex, + (f) => (f.payload as { contentBlockIndex: number }).contentBlockIndex, ); expect(indices).toEqual([0, 1, 2]); // Text block at index 0 - const textStart = blockStarts[0].payload as { - contentBlockStart: { start: { type: string } }; - }; - expect(textStart.contentBlockStart.start.type).toBe("text"); + const textStart = blockStarts[0].payload as { start: { type: string } }; + expect(textStart.start.type).toBe("text"); // Tool blocks at indices 1 and 2 const tool1Start = blockStarts[1].payload as { - contentBlockStart: { start: { toolUse: { name: string } } }; + start: { toolUse: { name: string } }; }; - expect(tool1Start.contentBlockStart.start.toolUse.name).toBe("tool_a"); + expect(tool1Start.start.toolUse.name).toBe("tool_a"); const tool2Start = blockStarts[2].payload as { - contentBlockStart: { start: { toolUse: { name: string } } }; + start: { toolUse: { name: string } }; }; - expect(tool2Start.contentBlockStart.start.toolUse.name).toBe("tool_b"); + expect(tool2Start.start.toolUse.name).toBe("tool_b"); - // contentBlockStop indices are also sequential + // contentBlockStop indices are also sequential — flat payloads const blockStops = frames.filter((f) => f.eventType === "contentBlockStop"); const stopIndices = blockStops.map( - (f) => - (f.payload as { contentBlockStop: { contentBlockIndex: number } }).contentBlockStop - .contentBlockIndex, + (f) => (f.payload as { contentBlockIndex: number }).contentBlockIndex, ); expect(stopIndices).toEqual([0, 1, 2]); - // messageStop with tool_use + // messageStop with tool_use — flat payload const msgStop = frames.find((f) => f.eventType === "messageStop"); - expect(msgStop!.payload).toEqual({ messageStop: { stopReason: "tool_use" } }); + expect(msgStop!.payload).toEqual({ stopReason: "tool_use" }); }); }); diff --git a/src/__tests__/drift/bedrock-stream.drift.ts b/src/__tests__/drift/bedrock-stream.drift.ts index 3f60f23..400edf6 100644 --- a/src/__tests__/drift/bedrock-stream.drift.ts +++ b/src/__tests__/drift/bedrock-stream.drift.ts @@ -2,13 +2,15 @@ * AWS Bedrock drift tests. * * Three-way comparison: SDK types x real API x aimock output. - * Covers invoke-with-response-stream and converse endpoints. + * Covers invoke-with-response-stream, converse, and converse-stream endpoints. */ +import http from "node:http"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { ServerInstance } from "../../server.js"; import { extractShape, triangulate, formatDriftReport, shouldFail } from "./schema.js"; import { httpPost, startDriftServer, stopDriftServer } from "./helpers.js"; +import { bedrockConverseStreamEventShapes } from "./sdk-shapes.js"; // --------------------------------------------------------------------------- // Credentials check @@ -79,6 +81,93 @@ function bedrockConverseResponseShape() { }); } +// --------------------------------------------------------------------------- +// Binary Event Stream helpers +// --------------------------------------------------------------------------- + +function httpPostBinary( + url: string, + body: object, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> { + return new Promise((resolve, reject) => { + const req = http.request( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode!, + headers: res.headers, + body: Buffer.concat(chunks), + }), + ); + }, + ); + req.on("error", reject); + req.write(JSON.stringify(body)); + req.end(); + }); +} + +interface ParsedFrame { + eventType: string; + messageType: string; + payload: unknown; +} + +function parseFrames(buf: Buffer): ParsedFrame[] { + const frames: ParsedFrame[] = []; + let offset = 0; + + while (offset < buf.length) { + const totalLength = buf.readUInt32BE(offset); + const frame = buf.subarray(offset, offset + totalLength); + + // Parse headers + const headersLength = frame.readUInt32BE(4); + const headersStart = 12; + const headersEnd = headersStart + headersLength; + const headers: Record = {}; + let hOffset = headersStart; + while (hOffset < headersEnd) { + const nameLen = frame.readUInt8(hOffset); + hOffset += 1; + const name = frame.subarray(hOffset, hOffset + nameLen).toString("utf8"); + hOffset += nameLen; + hOffset += 1; // type byte (7 = STRING) + const valueLen = frame.readUInt16BE(hOffset); + hOffset += 2; + const value = frame.subarray(hOffset, hOffset + valueLen).toString("utf8"); + hOffset += valueLen; + headers[name] = value; + } + + // Parse payload + const payloadStart = headersEnd; + const payloadEnd = totalLength - 4; + const payloadBuf = frame.subarray(payloadStart, payloadEnd); + let payload: unknown = null; + if (payloadBuf.length > 0) { + payload = JSON.parse(payloadBuf.toString("utf8")); + } + + frames.push({ + eventType: headers[":event-type"] ?? "", + messageType: headers[":message-type"] ?? "", + payload, + }); + + offset += totalLength; + } + + return frames; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -142,4 +231,109 @@ describe.skipIf(!HAS_CREDENTIALS)("Bedrock drift", () => { } } }); + + it("converse-stream payloads are flat (not double-wrapped with event type name)", async () => { + const mockRes = await httpPostBinary( + `${instance.url}/model/anthropic.claude-3-haiku-20240307-v1:0/converse-stream`, + { + messages: [ + { + role: "user", + content: [{ text: "Say hello" }], + }, + ], + inferenceConfig: { maxTokens: 10 }, + }, + ); + + expect(mockRes.status).toBe(200); + expect(mockRes.headers["content-type"]).toBe("application/vnd.amazon.eventstream"); + + const frames = parseFrames(mockRes.body); + expect(frames.length).toBeGreaterThanOrEqual(5); + + // ── Key event types must be present ─────────────────────────────── + const eventTypes = frames.map((f) => f.eventType); + for (const expected of [ + "messageStart", + "contentBlockStart", + "contentBlockDelta", + "contentBlockStop", + "messageStop", + "metadata", + ]) { + expect(eventTypes, `missing event type: ${expected}`).toContain(expected); + } + + // ── messageStart: flat { role: "assistant" } ────────────────────── + const msgStart = frames.find((f) => f.eventType === "messageStart"); + expect(msgStart).toBeDefined(); + const msgStartPayload = msgStart!.payload as Record; + expect(msgStartPayload).toEqual({ role: "assistant" }); + // Negative: must NOT be double-wrapped + expect(msgStartPayload).not.toHaveProperty("messageStart"); + + // ── contentBlockDelta: contains delta directly ──────────────────── + const deltaFrames = frames.filter((f) => f.eventType === "contentBlockDelta"); + expect(deltaFrames.length).toBeGreaterThanOrEqual(1); + for (const frame of deltaFrames) { + const payload = frame.payload as Record; + expect(payload).toHaveProperty("delta"); + expect(payload).toHaveProperty("contentBlockIndex"); + // Negative: must NOT be double-wrapped + expect(payload).not.toHaveProperty("contentBlockDelta"); + } + + // ── contentBlockStart: flat payload ─────────────────────────────── + const blockStarts = frames.filter((f) => f.eventType === "contentBlockStart"); + for (const frame of blockStarts) { + const payload = frame.payload as Record; + expect(payload).toHaveProperty("contentBlockIndex"); + expect(payload).toHaveProperty("start"); + expect(payload).not.toHaveProperty("contentBlockStart"); + } + + // ── contentBlockStop: flat payload ──────────────────────────────── + const blockStops = frames.filter((f) => f.eventType === "contentBlockStop"); + for (const frame of blockStops) { + const payload = frame.payload as Record; + expect(payload).toHaveProperty("contentBlockIndex"); + expect(payload).not.toHaveProperty("contentBlockStop"); + } + + // ── messageStop: flat { stopReason: "..." } ────────────────────── + const msgStop = frames.find((f) => f.eventType === "messageStop"); + expect(msgStop).toBeDefined(); + const msgStopPayload = msgStop!.payload as Record; + expect(msgStopPayload).toHaveProperty("stopReason"); + expect(msgStopPayload).not.toHaveProperty("messageStop"); + + // ── metadata: flat { usage: ..., metrics: ... } ────────────────── + const metadataFrame = frames.find((f) => f.eventType === "metadata"); + expect(metadataFrame).toBeDefined(); + const metadataPayload = metadataFrame!.payload as Record; + expect(metadataPayload).toHaveProperty("usage"); + expect(metadataPayload).toHaveProperty("metrics"); + expect(metadataPayload).not.toHaveProperty("metadata"); + + // ── Shape comparison against SDK expectations ───────────────────── + const sdkEvents = bedrockConverseStreamEventShapes(); + const mockEvents = frames.map((f) => ({ + type: f.eventType, + dataShape: extractShape(f.payload), + })); + + // Compare each SDK event type against the mock + for (const sdkEvent of sdkEvents) { + const mockEvent = mockEvents.find((m) => m.type === sdkEvent.type); + if (!mockEvent) continue; // already asserted presence above + + const diffs = triangulate(sdkEvent.dataShape, sdkEvent.dataShape, mockEvent.dataShape); + const report = formatDriftReport(`Bedrock ConverseStream:${sdkEvent.type}`, diffs); + + if (shouldFail(diffs)) { + expect.soft([], report).toEqual(diffs.filter((d) => d.severity === "critical")); + } + } + }); }); diff --git a/src/__tests__/drift/schema.ts b/src/__tests__/drift/schema.ts index 12a1d29..b6539d5 100644 --- a/src/__tests__/drift/schema.ts +++ b/src/__tests__/drift/schema.ts @@ -208,8 +208,6 @@ const ALLOWLISTED_PATHS = new Set([ "usageMetadata.totalTokenCount", "usageMetadata.cachedContentTokenCount", "system_fingerprint", - "logprobs", - "choices[].logprobs", "service_tier", "x_groq", // Gemini streaming metadata fields vary diff --git a/src/__tests__/drift/sdk-shapes.ts b/src/__tests__/drift/sdk-shapes.ts index 1c682e0..7d38296 100644 --- a/src/__tests__/drift/sdk-shapes.ts +++ b/src/__tests__/drift/sdk-shapes.ts @@ -181,6 +181,7 @@ export function openaiResponsesTextEventShapes(): SSEEventShape[] { type: "response.content_part.added", dataShape: extractShape({ type: "response.content_part.added", + item_id: "msg_abc123", output_index: 0, content_index: 0, part: { type: "output_text", text: "" }, @@ -200,6 +201,7 @@ export function openaiResponsesTextEventShapes(): SSEEventShape[] { type: "response.output_text.done", dataShape: extractShape({ type: "response.output_text.done", + item_id: "msg_abc123", output_index: 0, content_index: 0, text: "Hello!", @@ -209,6 +211,7 @@ export function openaiResponsesTextEventShapes(): SSEEventShape[] { type: "response.content_part.done", dataShape: extractShape({ type: "response.content_part.done", + item_id: "msg_abc123", output_index: 0, content_index: 0, part: { type: "output_text", text: "Hello!" }, @@ -720,6 +723,61 @@ export function geminiLiveToolCallEventShapes(): SSEEventShape[] { ]; } +// --------------------------------------------------------------------------- +// AWS Bedrock Converse Stream +// --------------------------------------------------------------------------- + +/** + * Expected event shapes for Bedrock ConverseStream responses. + * + * ConverseStream uses AWS binary Event Stream framing where the event type is + * carried in the `:event-type` header and the payload is a flat JSON object + * (NOT wrapped with the event type name as a key). + */ +export function bedrockConverseStreamEventShapes(): SSEEventShape[] { + return [ + { + type: "messageStart", + dataShape: extractShape({ + role: "assistant", + }), + }, + { + type: "contentBlockStart", + dataShape: extractShape({ + contentBlockIndex: 0, + start: { type: "text" }, + }), + }, + { + type: "contentBlockDelta", + dataShape: extractShape({ + contentBlockIndex: 0, + delta: { type: "text_delta", text: "Hello" }, + }), + }, + { + type: "contentBlockStop", + dataShape: extractShape({ + contentBlockIndex: 0, + }), + }, + { + type: "messageStop", + dataShape: extractShape({ + stopReason: "end_turn", + }), + }, + { + type: "metadata", + dataShape: extractShape({ + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + metrics: { latencyMs: 0 }, + }), + }, + ]; +} + // --------------------------------------------------------------------------- // Google Gemini (HTTP) // --------------------------------------------------------------------------- diff --git a/src/__tests__/gemini.test.ts b/src/__tests__/gemini.test.ts index ea5e7ea..56ae833 100644 --- a/src/__tests__/gemini.test.ts +++ b/src/__tests__/gemini.test.ts @@ -117,7 +117,7 @@ const errorFixture: Fixture = { response: { error: { message: "Rate limited", - type: "rate_limit_error", + type: "RESOURCE_EXHAUSTED", code: "rate_limit", }, status: 429, @@ -587,7 +587,7 @@ describe("Gemini error format conformance", () => { expect(body.error).toBeDefined(); expect(body.error.code).toBe(429); expect(body.error.message).toBe("Rate limited"); - expect(body.error.status).toBe("rate_limit_error"); + expect(body.error.status).toBe("RESOURCE_EXHAUSTED"); // Should NOT have OpenAI-style fields expect(body.error.type).toBeUndefined(); expect(body.status).toBeUndefined(); @@ -608,7 +608,7 @@ describe("Gemini error field preservation", () => { // Gemini format: { error: { code: , message, status: } } expect(body.error.code).toBe(429); expect(body.error.message).toBe("Rate limited"); - expect(body.error.status).toBe("rate_limit_error"); + expect(body.error.status).toBe("RESOURCE_EXHAUSTED"); }); }); diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index f92b634..1a45a28 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -328,6 +328,7 @@ describe("POST /api/chat (non-streaming)", () => { const body = JSON.parse(res.body); expect(body.model).toBe("llama3"); + expect(body.created_at).toEqual(expect.any(String)); expect(body.message.role).toBe("assistant"); expect(body.message.content).toBe("Hi there!"); expect(body.done).toBe(true); @@ -350,6 +351,7 @@ describe("POST /api/chat (non-streaming)", () => { expect(res.status).toBe(200); const body = JSON.parse(res.body); + expect(body.created_at).toEqual(expect.any(String)); expect(body.done).toBe(true); expect(body.message.tool_calls).toHaveLength(1); expect(body.message.tool_calls[0].function.name).toBe("get_weather"); @@ -377,15 +379,17 @@ describe("POST /api/chat (streaming)", () => { const chunks = parseNDJSON(res.body); expect(chunks.length).toBeGreaterThan(1); - // All non-final chunks should have done: false + // All non-final chunks should have done: false and created_at const nonFinal = chunks.slice(0, -1); for (const chunk of nonFinal) { expect((chunk as { done: boolean }).done).toBe(false); + expect((chunk as { created_at: string }).created_at).toEqual(expect.any(String)); } - // Final chunk should have done: true and all duration fields + // Final chunk should have done: true, created_at, and all duration fields const final = chunks[chunks.length - 1] as Record; expect(final.done).toBe(true); + expect(final.created_at).toEqual(expect.any(String)); expect(final.done_reason).toBe("stop"); expect(final.total_duration).toBe(0); expect(final.load_duration).toBe(0); @@ -445,6 +449,11 @@ describe("POST /api/chat (streaming)", () => { expect(toolChunk).toBeDefined(); expect(toolChunk!.message.tool_calls![0].function.name).toBe("get_weather"); expect(toolChunk!.message.tool_calls![0].function.arguments).toEqual({ city: "NYC" }); + + // All chunks should have created_at + for (const chunk of chunks) { + expect((chunk as { created_at: string }).created_at).toEqual(expect.any(String)); + } }); it("uses fixture chunkSize for text streaming", async () => { diff --git a/src/__tests__/reasoning-all-providers.test.ts b/src/__tests__/reasoning-all-providers.test.ts index 46fb174..fc6e7b0 100644 --- a/src/__tests__/reasoning-all-providers.test.ts +++ b/src/__tests__/reasoning-all-providers.test.ts @@ -519,36 +519,29 @@ describe("POST /model/{id}/converse-stream (reasoning streaming)", () => { expect(eventTypes[0]).toBe("messageStart"); - // Find thinking and text block starts + // Find thinking and text block starts — payloads are flat (not double-wrapped) const thinkingStartIdx = frames.findIndex( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { type?: string } } }).contentBlockStart - ?.start?.type === "thinking", + (f.payload as { start?: { type?: string } }).start?.type === "thinking", ); const textStartIdx = frames.findIndex( (f) => f.eventType === "contentBlockStart" && - (f.payload as { contentBlockStart?: { start?: { type?: string } } }).contentBlockStart - ?.start?.type === "text", + (f.payload as { start?: { type?: string } }).start?.type === "text", ); expect(thinkingStartIdx).toBeGreaterThan(0); expect(textStartIdx).toBeGreaterThan(thinkingStartIdx); - // Verify reasoning content appears in the stream + // Verify reasoning content appears in the stream — flat payloads const thinkingDeltas = frames.filter( (f) => f.eventType === "contentBlockDelta" && - (f.payload as { contentBlockDelta?: { delta?: { type?: string } } }).contentBlockDelta - ?.delta?.type === "thinking_delta", + (f.payload as { delta?: { type?: string } }).delta?.type === "thinking_delta", ); const fullThinking = thinkingDeltas - .map( - (f) => - (f.payload as { contentBlockDelta: { delta: { thinking: string } } }).contentBlockDelta - .delta.thinking, - ) + .map((f) => (f.payload as { delta: { thinking: string } }).delta.thinking) .join(""); expect(fullThinking).toBe("Let me think step by step about this problem."); @@ -568,8 +561,7 @@ describe("POST /model/{id}/converse-stream (reasoning streaming)", () => { const thinkingDeltas = frames.filter( (f) => f.eventType === "contentBlockDelta" && - (f.payload as { contentBlockDelta?: { delta?: { type?: string } } }).contentBlockDelta - ?.delta?.type === "thinking_delta", + (f.payload as { delta?: { type?: string } }).delta?.type === "thinking_delta", ); expect(thinkingDeltas).toHaveLength(0); }); diff --git a/src/__tests__/responses.test.ts b/src/__tests__/responses.test.ts index 400e3f9..a948410 100644 --- a/src/__tests__/responses.test.ts +++ b/src/__tests__/responses.test.ts @@ -425,6 +425,57 @@ describe("POST /v1/responses (streaming)", () => { } }); + it("output_text.done includes item_id", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/responses`, { + model: "gpt-4", + input: [{ role: "user", content: "hello" }], + stream: true, + }); + + const events = parseResponsesSSEEvents(res.body); + const doneEvents = events.filter((e) => e.type === "response.output_text.done"); + expect(doneEvents.length).toBeGreaterThan(0); + for (const d of doneEvents) { + expect(d.item_id).toBeDefined(); + expect(typeof d.item_id).toBe("string"); + } + }); + + it("content_part.added includes item_id", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/responses`, { + model: "gpt-4", + input: [{ role: "user", content: "hello" }], + stream: true, + }); + + const events = parseResponsesSSEEvents(res.body); + const addedEvents = events.filter((e) => e.type === "response.content_part.added"); + expect(addedEvents.length).toBeGreaterThan(0); + for (const d of addedEvents) { + expect(d.item_id).toBeDefined(); + expect(typeof d.item_id).toBe("string"); + } + }); + + it("content_part.done includes item_id", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/responses`, { + model: "gpt-4", + input: [{ role: "user", content: "hello" }], + stream: true, + }); + + const events = parseResponsesSSEEvents(res.body); + const doneEvents = events.filter((e) => e.type === "response.content_part.done"); + expect(doneEvents.length).toBeGreaterThan(0); + for (const d of doneEvents) { + expect(d.item_id).toBeDefined(); + expect(typeof d.item_id).toBe("string"); + } + }); + it("text deltas reconstruct full content", async () => { instance = await createServer(allFixtures); const res = await post(`${instance.url}/v1/responses`, { diff --git a/src/__tests__/ws-api-conformance.test.ts b/src/__tests__/ws-api-conformance.test.ts index 910c471..fded6f7 100644 --- a/src/__tests__/ws-api-conformance.test.ts +++ b/src/__tests__/ws-api-conformance.test.ts @@ -23,7 +23,7 @@ const TOOL_FIXTURE: Fixture = { const ERROR_FIXTURE: Fixture = { match: { userMessage: "error-test" }, response: { - error: { message: "Rate limited", type: "rate_limit_error" }, + error: { message: "Rate limited", type: "RESOURCE_EXHAUSTED" }, status: 429, }, }; @@ -793,7 +793,7 @@ describe("WS Gemini Live BidiGenerateContent conformance", () => { expect(msg.error).toBeDefined(); expect(msg.error.code).toBe(429); expect(msg.error.message).toBe("Rate limited"); - expect(msg.error.status).toBe("ERROR"); + expect(msg.error.status).toBe("RESOURCE_EXHAUSTED"); }); it("no-match error: code 404, status NOT_FOUND", async () => { diff --git a/src/__tests__/ws-gemini-live.test.ts b/src/__tests__/ws-gemini-live.test.ts index f53aab1..69472c9 100644 --- a/src/__tests__/ws-gemini-live.test.ts +++ b/src/__tests__/ws-gemini-live.test.ts @@ -20,7 +20,7 @@ const toolFixture: Fixture = { const errorFixture: Fixture = { match: { userMessage: "fail" }, response: { - error: { message: "Rate limited", type: "rate_limit_error", code: "rate_limit" }, + error: { message: "Rate limited", type: "RESOURCE_EXHAUSTED", code: "rate_limit" }, status: 429, }, }; @@ -224,7 +224,7 @@ describe("WebSocket Gemini Live BidiGenerateContent", () => { expect(msg.error).toBeDefined(); expect(msg.error.code).toBe(429); expect(msg.error.message).toBe("Rate limited"); - expect(msg.error.status).toBe("ERROR"); + expect(msg.error.status).toBe("RESOURCE_EXHAUSTED"); ws.close(); }); diff --git a/src/bedrock-converse.ts b/src/bedrock-converse.ts index 175622f..518393e 100644 --- a/src/bedrock-converse.ts +++ b/src/bedrock-converse.ts @@ -114,66 +114,56 @@ function buildBedrockStreamTextEvents( overrides?: ResponseOverrides, ): Array<{ eventType: string; payload: object }> { const events: Array<{ eventType: string; payload: object }> = [ - { eventType: "messageStart", payload: { messageStart: { role: "assistant" } } }, + { eventType: "messageStart", payload: { role: "assistant" } }, ]; if (reasoning) { const blockIndex = 0; events.push({ eventType: "contentBlockStart", - payload: { - contentBlockStart: { contentBlockIndex: blockIndex, start: { type: "thinking" } }, - }, + payload: { contentBlockIndex: blockIndex, start: { type: "thinking" } }, }); for (let i = 0; i < reasoning.length; i += chunkSize) { events.push({ eventType: "contentBlockDelta", payload: { - contentBlockDelta: { - contentBlockIndex: blockIndex, - delta: { type: "thinking_delta", thinking: reasoning.slice(i, i + chunkSize) }, - }, + contentBlockIndex: blockIndex, + delta: { type: "thinking_delta", thinking: reasoning.slice(i, i + chunkSize) }, }, }); } events.push({ eventType: "contentBlockStop", - payload: { contentBlockStop: { contentBlockIndex: blockIndex } }, + payload: { contentBlockIndex: blockIndex }, }); } const textBlockIndex = reasoning ? 1 : 0; events.push({ eventType: "contentBlockStart", - payload: { - contentBlockStart: { contentBlockIndex: textBlockIndex, start: { type: "text" } }, - }, + payload: { contentBlockIndex: textBlockIndex, start: { type: "text" } }, }); for (let i = 0; i < content.length; i += chunkSize) { events.push({ eventType: "contentBlockDelta", payload: { - contentBlockDelta: { - contentBlockIndex: textBlockIndex, - delta: { type: "text_delta", text: content.slice(i, i + chunkSize) }, - }, + contentBlockIndex: textBlockIndex, + delta: { type: "text_delta", text: content.slice(i, i + chunkSize) }, }, }); } events.push({ eventType: "contentBlockStop", - payload: { contentBlockStop: { contentBlockIndex: textBlockIndex } }, + payload: { contentBlockIndex: textBlockIndex }, }); events.push({ eventType: "messageStop", - payload: { - messageStop: { stopReason: converseStopReason(overrides?.finishReason, "end_turn") }, - }, + payload: { stopReason: converseStopReason(overrides?.finishReason, "end_turn") }, }); const usage = converseUsage(overrides); events.push({ eventType: "metadata", - payload: { metadata: { usage, metrics: { latencyMs: 0 } } }, + payload: { usage, metrics: { latencyMs: 0 } }, }); return events; } @@ -197,10 +187,8 @@ function buildBedrockStreamContentWithToolCallsEvents( events.push({ eventType: "contentBlockStart", payload: { - contentBlockStart: { - contentBlockIndex: blockIndex, - start: { toolUse: { toolUseId, name: tc.name } }, - }, + contentBlockIndex: blockIndex, + start: { toolUse: { toolUseId, name: tc.name } }, }, }); const argsStr = parseConverseToolArgumentsForStream(tc, logger); @@ -208,29 +196,25 @@ function buildBedrockStreamContentWithToolCallsEvents( events.push({ eventType: "contentBlockDelta", payload: { - contentBlockDelta: { - contentBlockIndex: blockIndex, - delta: { toolUse: { input: argsStr.slice(i, i + chunkSize) } }, - }, + contentBlockIndex: blockIndex, + delta: { toolUse: { input: argsStr.slice(i, i + chunkSize) } }, }, }); } events.push({ eventType: "contentBlockStop", - payload: { contentBlockStop: { contentBlockIndex: blockIndex } }, + payload: { contentBlockIndex: blockIndex }, }); blockIndex++; } events.push({ eventType: "messageStop", - payload: { - messageStop: { stopReason: converseStopReason(overrides?.finishReason, "tool_use") }, - }, + payload: { stopReason: converseStopReason(overrides?.finishReason, "tool_use") }, }); const usage = converseUsage(overrides); events.push({ eventType: "metadata", - payload: { metadata: { usage, metrics: { latencyMs: 0 } } }, + payload: { usage, metrics: { latencyMs: 0 } }, }); return events; } @@ -242,7 +226,7 @@ function buildBedrockStreamToolCallEvents( overrides?: ResponseOverrides, ): Array<{ eventType: string; payload: object }> { const events: Array<{ eventType: string; payload: object }> = [ - { eventType: "messageStart", payload: { messageStart: { role: "assistant" } } }, + { eventType: "messageStart", payload: { role: "assistant" } }, ]; for (let tcIdx = 0; tcIdx < toolCalls.length; tcIdx++) { @@ -251,10 +235,8 @@ function buildBedrockStreamToolCallEvents( events.push({ eventType: "contentBlockStart", payload: { - contentBlockStart: { - contentBlockIndex: tcIdx, - start: { toolUse: { toolUseId, name: tc.name } }, - }, + contentBlockIndex: tcIdx, + start: { toolUse: { toolUseId, name: tc.name } }, }, }); const argsStr = parseConverseToolArgumentsForStream(tc, logger); @@ -262,28 +244,24 @@ function buildBedrockStreamToolCallEvents( events.push({ eventType: "contentBlockDelta", payload: { - contentBlockDelta: { - contentBlockIndex: tcIdx, - delta: { toolUse: { input: argsStr.slice(i, i + chunkSize) } }, - }, + contentBlockIndex: tcIdx, + delta: { toolUse: { input: argsStr.slice(i, i + chunkSize) } }, }, }); } events.push({ eventType: "contentBlockStop", - payload: { contentBlockStop: { contentBlockIndex: tcIdx } }, + payload: { contentBlockIndex: tcIdx }, }); } events.push({ eventType: "messageStop", - payload: { - messageStop: { stopReason: converseStopReason(overrides?.finishReason, "tool_use") }, - }, + payload: { stopReason: converseStopReason(overrides?.finishReason, "tool_use") }, }); const usage = converseUsage(overrides); events.push({ eventType: "metadata", - payload: { metadata: { usage, metrics: { latencyMs: 0 } } }, + payload: { usage, metrics: { latencyMs: 0 } }, }); return events; } diff --git a/src/helpers.ts b/src/helpers.ts index e8db542..a18ad8e 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -209,7 +209,9 @@ export function buildTextChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: { reasoning_content: slice }, finish_reason: null }], + choices: [ + { index: 0, delta: { reasoning_content: slice }, logprobs: null, finish_reason: null }, + ], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); } @@ -225,6 +227,7 @@ export function buildTextChunks( { index: 0, delta: { role: overrides?.role ?? "assistant", content: "" }, + logprobs: null, finish_reason: null, }, ], @@ -239,7 +242,7 @@ export function buildTextChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: { content: slice }, finish_reason: null }], + choices: [{ index: 0, delta: { content: slice }, logprobs: null, finish_reason: null }], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); } @@ -250,7 +253,9 @@ export function buildTextChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: {}, finish_reason: overrides?.finishReason ?? "stop" }], + choices: [ + { index: 0, delta: {}, logprobs: null, finish_reason: overrides?.finishReason ?? "stop" }, + ], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); @@ -279,6 +284,7 @@ export function buildToolCallChunks( { index: 0, delta: { role: overrides?.role ?? "assistant", content: null }, + logprobs: null, finish_reason: null, }, ], @@ -309,6 +315,7 @@ export function buildToolCallChunks( }, ], }, + logprobs: null, finish_reason: null, }, ], @@ -330,6 +337,7 @@ export function buildToolCallChunks( delta: { tool_calls: [{ index: tcIdx, function: { arguments: slice } }], }, + logprobs: null, finish_reason: null, }, ], @@ -344,7 +352,14 @@ export function buildToolCallChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: {}, finish_reason: overrides?.finishReason ?? "tool_calls" }], + choices: [ + { + index: 0, + delta: {}, + logprobs: null, + finish_reason: overrides?.finishReason ?? "tool_calls", + }, + ], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); @@ -374,6 +389,7 @@ export function buildTextCompletion( refusal: null, ...(reasoning ? { reasoning_content: reasoning } : {}), }, + logprobs: null, finish_reason: overrides?.finishReason ?? "stop", }, ], @@ -416,6 +432,7 @@ export function buildToolCallCompletion( function: { name: tc.name, arguments: tc.arguments }, })), }, + logprobs: null, finish_reason: overrides?.finishReason ?? "tool_calls", }, ], @@ -457,7 +474,9 @@ export function buildContentWithToolCallsChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: { reasoning_content: slice }, finish_reason: null }], + choices: [ + { index: 0, delta: { reasoning_content: slice }, logprobs: null, finish_reason: null }, + ], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); } @@ -473,6 +492,7 @@ export function buildContentWithToolCallsChunks( { index: 0, delta: { role: overrides?.role ?? "assistant", content: "" }, + logprobs: null, finish_reason: null, }, ], @@ -487,7 +507,7 @@ export function buildContentWithToolCallsChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: { content: slice }, finish_reason: null }], + choices: [{ index: 0, delta: { content: slice }, logprobs: null, finish_reason: null }], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); } @@ -516,6 +536,7 @@ export function buildContentWithToolCallsChunks( }, ], }, + logprobs: null, finish_reason: null, }, ], @@ -537,6 +558,7 @@ export function buildContentWithToolCallsChunks( delta: { tool_calls: [{ index: tcIdx, function: { arguments: slice } }], }, + logprobs: null, finish_reason: null, }, ], @@ -551,7 +573,14 @@ export function buildContentWithToolCallsChunks( object: "chat.completion.chunk", created, model: effectiveModel, - choices: [{ index: 0, delta: {}, finish_reason: overrides?.finishReason ?? "tool_calls" }], + choices: [ + { + index: 0, + delta: {}, + logprobs: null, + finish_reason: overrides?.finishReason ?? "tool_calls", + }, + ], ...(fingerprint !== undefined && { system_fingerprint: fingerprint }), }); @@ -585,6 +614,7 @@ export function buildContentWithToolCallsCompletion( function: { name: tc.name, arguments: tc.arguments }, })), }, + logprobs: null, finish_reason: overrides?.finishReason ?? "tool_calls", }, ], diff --git a/src/ollama.ts b/src/ollama.ts index 11af03a..b78f274 100644 --- a/src/ollama.ts +++ b/src/ollama.ts @@ -136,6 +136,7 @@ function buildOllamaChatTextChunks( reasoning?: string, ): object[] { const chunks: object[] = []; + const createdAt = new Date().toISOString(); // Reasoning chunks (before content) if (reasoning) { @@ -143,6 +144,7 @@ function buildOllamaChatTextChunks( const slice = reasoning.slice(i, i + chunkSize); chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "", reasoning_content: slice }, done: false, }); @@ -153,6 +155,7 @@ function buildOllamaChatTextChunks( const slice = content.slice(i, i + chunkSize); chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: slice }, done: false, }); @@ -161,6 +164,7 @@ function buildOllamaChatTextChunks( // Final chunk with done: true and all duration fields chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "" }, done: true, ...DURATION_FIELDS, @@ -172,6 +176,7 @@ function buildOllamaChatTextChunks( function buildOllamaChatTextResponse(content: string, model: string, reasoning?: string): object { return { model, + created_at: new Date().toISOString(), message: { role: "assistant", content, @@ -207,8 +212,10 @@ function buildOllamaChatToolCallChunks( // Tool calls are sent in a single chunk (no streaming of individual args) const chunks: object[] = []; + const createdAt = new Date().toISOString(); chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "", @@ -220,6 +227,7 @@ function buildOllamaChatToolCallChunks( // Final chunk chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "" }, done: true, ...DURATION_FIELDS, @@ -253,6 +261,7 @@ function buildOllamaChatToolCallResponse( return { model, + created_at: new Date().toISOString(), message: { role: "assistant", content: "", @@ -273,12 +282,14 @@ function buildOllamaChatContentWithToolCallsChunks( logger: Logger, ): object[] { const chunks: object[] = []; + const createdAt = new Date().toISOString(); // Content chunks first for (let i = 0; i < content.length; i += chunkSize) { const slice = content.slice(i, i + chunkSize); chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: slice }, done: false, }); @@ -305,6 +316,7 @@ function buildOllamaChatContentWithToolCallsChunks( chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "", @@ -316,6 +328,7 @@ function buildOllamaChatContentWithToolCallsChunks( // Final chunk chunks.push({ model, + created_at: createdAt, message: { role: "assistant", content: "" }, done: true, ...DURATION_FIELDS, @@ -350,6 +363,7 @@ function buildOllamaChatContentWithToolCallsResponse( return { model, + created_at: new Date().toISOString(), message: { role: "assistant", content, diff --git a/src/responses.ts b/src/responses.ts index 98174b9..010ea09 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -620,6 +620,7 @@ function buildMessageOutputEvents( }); events.push({ type: "response.content_part.added", + item_id: msgId, output_index: outputIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] }, @@ -637,12 +638,14 @@ function buildMessageOutputEvents( events.push({ type: "response.output_text.done", + item_id: msgId, output_index: outputIndex, content_index: 0, text: content, }); events.push({ type: "response.content_part.done", + item_id: msgId, output_index: outputIndex, content_index: 0, part: { type: "output_text", text: content, annotations: [] }, diff --git a/src/types.ts b/src/types.ts index 7318de9..238103c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -378,6 +378,7 @@ export interface SSEChunk { export interface SSEChoice { index: number; delta: SSEDelta; + logprobs: null; finish_reason: string | null; } @@ -410,6 +411,7 @@ export interface ChatCompletion { export interface ChatCompletionChoice { index: number; message: ChatCompletionMessage; + logprobs: null; finish_reason: string; } diff --git a/src/ws-gemini-live.ts b/src/ws-gemini-live.ts index 5b08be0..a5fc2f5 100644 --- a/src/ws-gemini-live.ts +++ b/src/ws-gemini-live.ts @@ -390,7 +390,11 @@ async function processMessage( }); ws.send( JSON.stringify({ - error: { code: status, message: response.error.message, status: "ERROR" }, + error: { + code: status, + message: response.error.message, + status: response.error.type ?? "INTERNAL", + }, }), ); return;