diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d1fcb56a..9d4907ef21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(api): cache-HIT `X-OmniRoute-Response-Cost` now reports the incremental cost (≈0), not the original** — on a semantic-cache HIT the gateway serves the stored response **without** an upstream call, but `X-OmniRoute-Response-Cost` was reporting the original call's full cost (recomputed from the cached `usage`). A consumer summing `response-cost` for billing was therefore charging for responses that cost ≈$0 to serve (and stale entries could inflate it). Cache hits now bill `X-OmniRoute-Response-Cost: 0.0000000000` (the real incremental cost), and the avoided cost is surfaced in a new **`X-OmniRoute-Cost-Saved`** header for cache analytics — mirroring the existing `tokens_saved` concept. The MISS path is unchanged. (PRD-2026-06-19-cache-hit-cost-reporting) - **fix(models): imported vision-capable models keep their vision capability** — after importing a provider key, vision-capable models (e.g. OpenRouter models whose `architecture` declares image input, and other synced providers) were listed as text-only in `/v1/models` and the dashboard — even though image requests actually worked. Synced model records never captured the vision flag, and the catalog's OpenRouter live-enrichment (which derives vision from `architecture.input_modalities`) is skipped once a provider has synced models. Discovery now captures `supportsVision` at sync time (from `architecture.input_modalities`, the string `architecture.modality`, or a top-level `input_modalities`), mirroring the existing `supportsThinking` capture, and the catalog surfaces `capabilities.vision` for synced models. ([#4264](https://github.com/diegosouzapw/OmniRoute/issues/4264) — thanks @FerLuisxd) - **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd) +- **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap) - **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f01f8cdc10..0930929b56 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -209,7 +209,7 @@ "tests/unit/token-refresh-service.test.ts": 1322, "tests/unit/translator-friendly-test-bench.test.tsx": 848, "tests/unit/translator-helper-branches.test.ts": 850, - "tests/unit/translator-openai-responses-req.test.ts": 1011, + "tests/unit/translator-openai-responses-req.test.ts": 1047, "tests/unit/translator-openai-to-gemini.test.ts": 1579, "tests/unit/translator-openai-to-kiro.test.ts": 918, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index e5fa0c2e47..82958a5ea9 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -35,6 +35,14 @@ function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +// The Responses API rejects call_id values longer than 64 characters (9router#396). +// Clamp deterministically so a function_call and its matching function_call_output keep +// the same id and stay paired through the orphaned-output filter below. +const MAX_CALL_ID_LEN = 64; +function clampCallId(id: string): string { + return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id; +} + function toArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } @@ -579,7 +587,7 @@ export function openaiToOpenAIResponsesRequest( } input.push({ type: "function_call", - call_id: toString(toolCall.id).trim() || generateToolCallId(), + call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), name: fnName, arguments: toString(fn.arguments, "{}"), }); @@ -593,7 +601,7 @@ export function openaiToOpenAIResponsesRequest( if (fnName) { input.push({ type: "function_call", - call_id: `call_${fnName}`, + call_id: clampCallId(`call_${fnName}`), name: fnName, arguments: toString(fc.arguments, "{}"), }); @@ -605,7 +613,7 @@ export function openaiToOpenAIResponsesRequest( if (role === "tool") { input.push({ type: "function_call_output", - call_id: toString(msg.tool_call_id), + call_id: clampCallId(toString(msg.tool_call_id)), output: typeof msg.content === "string" ? msg.content @@ -624,7 +632,7 @@ export function openaiToOpenAIResponsesRequest( if (role === "function") { input.push({ type: "function_call_output", - call_id: `call_${toString(msg.name)}`, + call_id: clampCallId(`call_${toString(msg.name)}`), output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), }); } diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 5b97b84a7f..b20ac64bb0 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -252,6 +252,42 @@ test("Responses -> Chat strips client_metadata (Mistral 422 fix)", () => { assert.equal((result.messages as unknown[]).length, 1, "user message must be preserved"); }); +test("Chat -> Responses clamps call_id to 64 chars and keeps the pair matched (port from 9router#396)", () => { + // The Responses API rejects call_id values longer than 64 characters. A long + // upstream tool-call id must be clamped on BOTH the function_call and its matching + // function_call_output, identically, so the orphan filter still pairs them. + const longId = "call_" + "a".repeat(80); // 85 chars, > 64 + const result = openaiToOpenAIResponsesRequest( + "gpt-4o", + { + messages: [ + { + role: "assistant", + content: null, + tool_calls: [ + { id: longId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: longId, content: "ok" }, + ], + }, + false, + null + ) as any; + + const input = result.input as Array>; + const fnCall = input.find((i) => i.type === "function_call"); + const fnOut = input.find((i) => i.type === "function_call_output"); + assert.ok(fnCall, "function_call item must exist"); + assert.equal(fnCall.call_id.length, 64, "function_call call_id must be clamped to 64 chars"); + assert.ok(fnOut, "function_call_output must survive the orphan filter after clamping"); + assert.equal( + fnOut.call_id, + fnCall.call_id, + "output call_id must match the clamped function_call id" + ); +}); + test("Chat -> Responses converts messages, tool calls, tool outputs, tools and pass-through params", () => { const result = openaiToOpenAIResponsesRequest( "gpt-4o",