diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4c496659..dbea694143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _In development β€” bullets added per PR; finalized at release._ ### πŸ› Fixed +- **fix(executors): preserve tool-name casing on native Claude OAuth (`read` no longer leaks back as `Read`)** β€” native Claude OAuth traffic runs through an anti-fingerprint tool-name cloak that renames a tool literally named `read` to `Read` on the wire and records the reverse alias on a non-enumerable `_toolNameMap`, which the response side uses to restore the client's original casing. Since v3.8.27 the executor returned a JSON-round-tripped copy of the body as `transformedBody`, and that round-trip dropped the non-enumerable map β€” so the restore saw an empty map and the cloaked `Read` streamed verbatim to the client, corrupting the tool name. The executor now re-attaches the cloak map onto the serialized body (mirroring the Antigravity executor), so tool-name casing round-trips correctly. ([#4307](https://github.com/diegosouzapw/OmniRoute/issues/4307) β€” thanks @dev-cj) - **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) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index abb0fd0258..6b848a7d2b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -70,7 +70,7 @@ "frozen": { "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1664, - "open-sse/executors/base.ts": 1358, + "open-sse/executors/base.ts": 1387, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 900117470c..513ff1c45b 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1224,6 +1224,35 @@ export class BaseExecutor { mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); const serializedBody = prl.parseBody(bodyString); + // #4307 β€” Preserve the non-enumerable tool-name cloak/remap reverse map + // (`_toolNameMap`, set on the live `transformedBody` by + // remapToolNamesInRequest / cloakThirdPartyToolNames) that the JSON + // round-trip above drops. chatCore's response-side un-cloak reads it off + // `result.transformedBody` to restore the client's original tool-name + // casing (e.g. `read`, not the cloaked `Read`). Without this re-attach the + // map is lost and the client receives the cloaked casing β€” a regression + // from #3941's serialized-body capture. Mirrors antigravity.ts's + // `attachToolNameMap`; non-enumerable so it never re-serializes upstream. + if ( + transformedBody && + typeof transformedBody === "object" && + serializedBody && + typeof serializedBody === "object" + ) { + const liveToolNameMap = (transformedBody as Record)._toolNameMap; + if ( + liveToolNameMap instanceof Map && + liveToolNameMap.size > 0 && + !((serializedBody as Record)._toolNameMap instanceof Map) + ) { + Object.defineProperty(serializedBody, "_toolNameMap", { + value: liveToolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); + } + } const fetchOptions: RequestInit = { method: "POST", headers: finalHeaders, diff --git a/tests/unit/tool-name-case-preserve-4307.test.ts b/tests/unit/tool-name-case-preserve-4307.test.ts new file mode 100644 index 0000000000..f91aaae51c --- /dev/null +++ b/tests/unit/tool-name-case-preserve-4307.test.ts @@ -0,0 +1,99 @@ +/** + * #4307 β€” Tool name case changes (`read` -> `Read`) leaks to the client. + * + * Native Claude OAuth traffic runs through the anti-fingerprint tool-name cloak + * (`remapToolNamesInRequest`/`cloakThirdPartyToolNames`), which renames a tool + * literally named `read` to `Read` and records the reverse alias (`Read -> read`) + * on a NON-ENUMERABLE `body._toolNameMap`. The response side un-cloaks the + * streamed `tool_use.name` back to the client's original casing using that map. + * + * Regression (v3.8.27 / #3941/#3968): `execute()` started returning a + * JSON-round-tripped `serializedBody` as `result.transformedBody`. The round-trip + * drops the non-enumerable `_toolNameMap`, so chatCore's response-side restore + * sees an empty map and the cloaked `Read` streams verbatim to the client β€” the + * tool name case is corrupted (`read` -> `Read`). + * + * This test pins the executor boundary: after `execute()` runs the claude-OAuth + * cloak, the returned `transformedBody` MUST still carry the per-request + * `_toolNameMap` (non-enumerable, so it never re-serializes upstream). + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; + +// Minimal `claude` executor: passthrough transformRequest, no credential refresh, +// so we exercise exactly base.ts's cloak + serialize-and-return path. +class ClaudeLikeExecutor extends BaseExecutor { + constructor() { + super("claude", { baseUrls: ["https://api.anthropic.com/v1/messages"] }); + } + // Never trigger the refresh branch in execute(). + needsRefresh() { + return false; + } + async transformRequest(_model: string, body: Record) { + return { ...body }; + } +} + +test("#4307 execute() preserves the tool-name cloak map (read->Read reverse) on the returned body", async () => { + const executor = new ClaudeLikeExecutor(); + const originalFetch = globalThis.fetch; + let upstreamBody: Record | null = null; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + upstreamBody = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + after(() => { + globalThis.fetch = originalFetch; + }); + + const result = await executor.execute({ + model: "claude-sonnet-4-5", + body: { + messages: [{ role: "user", content: "hi" }], + // A tool literally named `read` (lower-case) β€” must round-trip unchanged + // back to the client. + tools: [{ name: "read", description: "read a file", input_schema: { type: "object" } }], + }, + stream: false, + // OAuth token (sk-ant-oat…) with NO apiKey => hasClaudeOAuthToken => cloak fires. + credentials: { accessToken: "sk-ant-oat-test-token-4307" }, + }); + + // Precondition: the cloak actually fired β€” the body sent UPSTREAM carries the + // TitleCase alias `Read` (this is intentional anti-fingerprint behavior). + assert.ok(upstreamBody, "fetch must have been called"); + const upstreamToolName = (upstreamBody!.tools as Array<{ name: string }>)[0].name; + assert.equal(upstreamToolName, "Read", "precondition: cloak renames read -> Read on the wire"); + // And the cloak map never re-serializes onto the wire (stays non-enumerable). + assert.equal( + JSON.stringify(upstreamBody).includes("_toolNameMap"), + false, + "_toolNameMap must never appear in the serialized upstream body" + ); + + // The actual regression guard: the returned transformedBody must still carry + // the reverse map so chatCore can restore `Read` -> `read` for the client. + const returned = result.transformedBody as Record; + const map = returned._toolNameMap; + assert.ok( + map instanceof Map, + "result.transformedBody must carry the non-enumerable _toolNameMap (dropped by the v3.8.27 serialize round-trip without the #4307 fix)" + ); + assert.equal( + (map as Map).get("Read"), + "read", + "reverse map must restore the client's original tool-name casing" + ); + // The re-attached map must remain non-enumerable (never re-serializes upstream). + assert.equal( + Object.keys(returned).includes("_toolNameMap"), + false, + "_toolNameMap must stay non-enumerable on the returned body" + ); +});