Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/tool-name-case-preserve-4307.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
Loading