fix(translator): clamp Responses API call_id to 64 chars (#4317)

* fix(translator): clamp Responses API call_id to 64 chars (port from 9router#396)

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)
were forwarded verbatim. Clamp the id 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.

Reported-by: ngapngap (https://github.com/decolua/9router/issues/393)
Co-authored-by: Anurag Saxena <17893081+anuragg-saxenaa@users.noreply.github.com>
Co-authored-by: ngapngap <27039619+ngapngap@users.noreply.github.com>

* chore(quality): bump translator-openai-responses-req file-size baseline 1011->1047

The clamp-call_id regression test (+36 lines) grew the test file past its frozen
baseline; bump it in the same change (Rule #9).

---------

Co-authored-by: Anurag Saxena <17893081+anuragg-saxenaa@users.noreply.github.com>
Co-authored-by: ngapngap <27039619+ngapngap@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 21:33:28 -03:00
committed by GitHub
parent 032387401a
commit 0abc00cf2a
4 changed files with 50 additions and 5 deletions

View File

@@ -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: "<uuid>", 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)
---

View File

@@ -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,

View File

@@ -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 ?? ""),
});
}

View File

@@ -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<Record<string, any>>;
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",