Skip to content

Commit 36eb40e

Browse files
🤖 fix: strip mux-gateway prefix from model strings (#809)
Gateway models like `mux-gateway:anthropic/claude-opus-4-5` were not recognized for cost tracking, 1M context detection, or cache control because they don't match the expected `provider:model` format. ## Changes Add `normalizeGatewayModel()` utility that converts: - `mux-gateway:provider/model` → `provider:model` Applied at entry points of: - `getModelStats()` - cost/token lookups - `supports1MContext()` - 1M context detection - `supportsAnthropicCache()` - cache control - `tokenizer resolveModelName()` - tokenizer model resolution - `getModelName()` - model name extraction ## Testing Added unit tests for the new utility and updated existing test files. _Generated with `mux`_
1 parent ebb8e1b commit 36eb40e

File tree

7 files changed

+132
-144
lines changed

7 files changed

+132
-144
lines changed

bun.lock

Lines changed: 8 additions & 133 deletions
Large diffs are not rendered by default.

src/common/utils/ai/cacheStrategy.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { tool as createTool, type ModelMessage, type Tool } from "ai";
2+
import { normalizeGatewayModel } from "./models";
23

34
/**
45
* Check if a model supports Anthropic cache control.
@@ -8,12 +9,13 @@ import { tool as createTool, type ModelMessage, type Tool } from "ai";
89
* - OpenRouter Anthropic models: "openrouter:anthropic/claude-3.5-sonnet"
910
*/
1011
export function supportsAnthropicCache(modelString: string): boolean {
11-
// Direct Anthropic provider
12-
if (modelString.startsWith("anthropic:")) {
12+
const normalized = normalizeGatewayModel(modelString);
13+
// Direct Anthropic provider (or normalized gateway model)
14+
if (normalized.startsWith("anthropic:")) {
1315
return true;
1416
}
15-
// Gateway/router providers routing to Anthropic (format: "provider:anthropic/model")
16-
const [, modelId] = modelString.split(":");
17+
// Other gateway/router providers routing to Anthropic (format: "provider:anthropic/model")
18+
const [, modelId] = normalized.split(":");
1719
if (modelId?.startsWith("anthropic/")) {
1820
return true;
1921
}

src/common/utils/ai/models.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { normalizeGatewayModel, getModelName, supports1MContext } from "./models";
3+
4+
describe("normalizeGatewayModel", () => {
5+
it("should convert mux-gateway:provider/model to provider:model", () => {
6+
expect(normalizeGatewayModel("mux-gateway:anthropic/claude-opus-4-5")).toBe(
7+
"anthropic:claude-opus-4-5"
8+
);
9+
expect(normalizeGatewayModel("mux-gateway:openai/gpt-4o")).toBe("openai:gpt-4o");
10+
expect(normalizeGatewayModel("mux-gateway:google/gemini-2.5-pro")).toBe(
11+
"google:gemini-2.5-pro"
12+
);
13+
});
14+
15+
it("should return non-gateway strings unchanged", () => {
16+
expect(normalizeGatewayModel("anthropic:claude-opus-4-5")).toBe("anthropic:claude-opus-4-5");
17+
expect(normalizeGatewayModel("openai:gpt-4o")).toBe("openai:gpt-4o");
18+
expect(normalizeGatewayModel("claude-opus-4-5")).toBe("claude-opus-4-5");
19+
});
20+
21+
it("should return malformed gateway strings unchanged", () => {
22+
// No slash in the inner part
23+
expect(normalizeGatewayModel("mux-gateway:no-slash-here")).toBe("mux-gateway:no-slash-here");
24+
});
25+
});
26+
27+
describe("getModelName", () => {
28+
it("should extract model name from provider:model format", () => {
29+
expect(getModelName("anthropic:claude-opus-4-5")).toBe("claude-opus-4-5");
30+
expect(getModelName("openai:gpt-4o")).toBe("gpt-4o");
31+
});
32+
33+
it("should handle mux-gateway format", () => {
34+
expect(getModelName("mux-gateway:anthropic/claude-opus-4-5")).toBe("claude-opus-4-5");
35+
expect(getModelName("mux-gateway:openai/gpt-4o")).toBe("gpt-4o");
36+
});
37+
38+
it("should return full string if no colon", () => {
39+
expect(getModelName("claude-opus-4-5")).toBe("claude-opus-4-5");
40+
});
41+
});
42+
43+
describe("supports1MContext", () => {
44+
it("should return true for Anthropic Sonnet 4 models", () => {
45+
expect(supports1MContext("anthropic:claude-sonnet-4-5")).toBe(true);
46+
expect(supports1MContext("anthropic:claude-sonnet-4-5-20250514")).toBe(true);
47+
expect(supports1MContext("anthropic:claude-sonnet-4-20250514")).toBe(true);
48+
});
49+
50+
it("should return true for mux-gateway Sonnet 4 models", () => {
51+
expect(supports1MContext("mux-gateway:anthropic/claude-sonnet-4-5")).toBe(true);
52+
expect(supports1MContext("mux-gateway:anthropic/claude-sonnet-4-5-20250514")).toBe(true);
53+
});
54+
55+
it("should return false for non-Anthropic models", () => {
56+
expect(supports1MContext("openai:gpt-4o")).toBe(false);
57+
expect(supports1MContext("mux-gateway:openai/gpt-4o")).toBe(false);
58+
});
59+
60+
it("should return false for Anthropic non-Sonnet-4 models", () => {
61+
expect(supports1MContext("anthropic:claude-opus-4-5")).toBe(false);
62+
expect(supports1MContext("anthropic:claude-haiku-4-5")).toBe(false);
63+
expect(supports1MContext("mux-gateway:anthropic/claude-opus-4-5")).toBe(false);
64+
});
65+
});

src/common/utils/ai/models.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,38 @@ import { DEFAULT_MODEL } from "@/common/constants/knownModels";
66

77
export const defaultModel = DEFAULT_MODEL;
88

9+
const MUX_GATEWAY_PREFIX = "mux-gateway:";
10+
11+
/**
12+
* Normalize gateway-prefixed model strings to standard format.
13+
* Converts "mux-gateway:provider/model" to "provider:model".
14+
* Returns non-gateway strings unchanged.
15+
*/
16+
export function normalizeGatewayModel(modelString: string): string {
17+
if (!modelString.startsWith(MUX_GATEWAY_PREFIX)) {
18+
return modelString;
19+
}
20+
// mux-gateway:anthropic/claude-opus-4-5 → anthropic:claude-opus-4-5
21+
const inner = modelString.slice(MUX_GATEWAY_PREFIX.length);
22+
const slashIndex = inner.indexOf("/");
23+
if (slashIndex === -1) {
24+
return modelString; // Malformed, return as-is
25+
}
26+
return `${inner.slice(0, slashIndex)}:${inner.slice(slashIndex + 1)}`;
27+
}
28+
929
/**
1030
* Extract the model name from a model string (e.g., "anthropic:claude-sonnet-4-5" -> "claude-sonnet-4-5")
1131
* @param modelString - Full model string in format "provider:model-name"
1232
* @returns The model name part (after the colon), or the full string if no colon is found
1333
*/
1434
export function getModelName(modelString: string): string {
15-
const colonIndex = modelString.indexOf(":");
35+
const normalized = normalizeGatewayModel(modelString);
36+
const colonIndex = normalized.indexOf(":");
1637
if (colonIndex === -1) {
17-
return modelString;
38+
return normalized;
1839
}
19-
return modelString.substring(colonIndex + 1);
40+
return normalized.substring(colonIndex + 1);
2041
}
2142

2243
/**
@@ -26,7 +47,8 @@ export function getModelName(modelString: string): string {
2647
* @returns True if the model supports 1M context window
2748
*/
2849
export function supports1MContext(modelString: string): boolean {
29-
const [provider, modelName] = modelString.split(":");
50+
const normalized = normalizeGatewayModel(modelString);
51+
const [provider, modelName] = normalized.split(":");
3052
if (provider !== "anthropic") {
3153
return false;
3254
}

src/common/utils/tokens/modelStats.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ describe("getModelStats", () => {
8888
});
8989
});
9090

91+
describe("mux-gateway models", () => {
92+
test("should handle mux-gateway:anthropic/model format", () => {
93+
const stats = getModelStats("mux-gateway:anthropic/claude-sonnet-4-5");
94+
expect(stats).not.toBeNull();
95+
expect(stats?.input_cost_per_token).toBe(0.000003);
96+
expect(stats?.output_cost_per_token).toBe(0.000015);
97+
});
98+
99+
test("should handle mux-gateway:openai/model format", () => {
100+
const stats = getModelStats("mux-gateway:openai/gpt-4o");
101+
expect(stats).not.toBeNull();
102+
expect(stats?.max_input_tokens).toBeGreaterThan(0);
103+
});
104+
105+
test("should return null for mux-gateway with unknown model", () => {
106+
const stats = getModelStats("mux-gateway:anthropic/unknown-model-xyz");
107+
expect(stats).toBeNull();
108+
});
109+
});
110+
91111
describe("model without provider prefix", () => {
92112
test("should handle model string without provider", () => {
93113
const stats = getModelStats("gpt-5.1");

src/common/utils/tokens/modelStats.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import modelsData from "./models.json";
22
import { modelsExtra } from "./models-extra";
3+
import { normalizeGatewayModel } from "../ai/models";
34

45
export interface ModelStats {
56
max_input_tokens: number;
@@ -92,7 +93,8 @@ function generateLookupKeys(modelString: string): string[] {
9293
* @returns ModelStats or null if model not found
9394
*/
9495
export function getModelStats(modelString: string): ModelStats | null {
95-
const lookupKeys = generateLookupKeys(modelString);
96+
const normalized = normalizeGatewayModel(modelString);
97+
const lookupKeys = generateLookupKeys(normalized);
9698

9799
// Try each lookup pattern in main models.json
98100
for (const key of lookupKeys) {

src/node/utils/main/tokenizer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { CountTokensInput } from "./tokenizer.worker";
66
import { models, type ModelName } from "ai-tokenizer";
77
import { run } from "./workerPool";
88
import { TOKENIZER_MODEL_OVERRIDES, DEFAULT_WARM_MODELS } from "@/common/constants/knownModels";
9+
import { normalizeGatewayModel } from "@/common/utils/ai/models";
910

1011
/**
1112
* Public tokenizer interface exposed to callers.
@@ -48,10 +49,11 @@ function normalizeModelKey(modelName: string): ModelName | null {
4849
* Optionally logs a warning when falling back.
4950
*/
5051
function resolveModelName(modelString: string): ModelName {
51-
let modelName = normalizeModelKey(modelString);
52+
const normalized = normalizeGatewayModel(modelString);
53+
let modelName = normalizeModelKey(normalized);
5254

5355
if (!modelName) {
54-
const provider = modelString.split(":")[0] || "anthropic";
56+
const provider = normalized.split(":")[0] || "anthropic";
5557
const fallbackModel =
5658
provider === "anthropic"
5759
? "anthropic/claude-sonnet-4.5"

0 commit comments

Comments
 (0)