fix(executors): preserve tool-name casing on native Claude OAuth (#4307) (#4314)

The native-Claude OAuth anti-fingerprint cloak renames a tool named `read`
to `Read` on the wire and records the reverse alias on a non-enumerable
`_toolNameMap`, which the response side un-cloaks to restore the client's
original casing. Since v3.8.27 (#3941/#3968) `execute()` returned a
JSON-round-tripped `serializedBody` as `transformedBody`; the round-trip
drops the non-enumerable map, so the restore saw an empty map and the
cloaked `Read` streamed verbatim to the client.

Re-attach the live `_toolNameMap` onto the serialized body before returning
(non-enumerable, mirrors antigravity.ts::attachToolNameMap) so tool-name
casing round-trips correctly.

Regression test exercises base.ts execute() through the claude-OAuth cloak
path and asserts the returned transformedBody carries the reverse map.

Closes #4307
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 20:12:03 -03:00
committed by GitHub
parent a23d0d678a
commit 6103288c48
4 changed files with 130 additions and 1 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>)._toolNameMap;
if (
liveToolNameMap instanceof Map &&
liveToolNameMap.size > 0 &&
!((serializedBody as Record<string, unknown>)._toolNameMap instanceof Map)
) {
Object.defineProperty(serializedBody, "_toolNameMap", {
value: liveToolNameMap,
enumerable: false,
configurable: true,
writable: true,
});
}
}
const fetchOptions: RequestInit = {
method: "POST",
headers: finalHeaders,

View File

@@ -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<string, unknown>) {
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<string, unknown> | 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<string, unknown>;
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<string, string>).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"
);
});