mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
a23d0d678a
commit
6103288c48
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
tests/unit/tool-name-case-preserve-4307.test.ts
Normal file
99
tests/unit/tool-name-case-preserve-4307.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user