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
141 changes: 8 additions & 133 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions src/common/utils/ai/cacheStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { tool as createTool, type ModelMessage, type Tool } from "ai";
import { normalizeGatewayModel } from "./models";

/**
* Check if a model supports Anthropic cache control.
Expand All @@ -8,12 +9,13 @@ import { tool as createTool, type ModelMessage, type Tool } from "ai";
* - OpenRouter Anthropic models: "openrouter:anthropic/claude-3.5-sonnet"
*/
export function supportsAnthropicCache(modelString: string): boolean {
// Direct Anthropic provider
if (modelString.startsWith("anthropic:")) {
const normalized = normalizeGatewayModel(modelString);
// Direct Anthropic provider (or normalized gateway model)
if (normalized.startsWith("anthropic:")) {
return true;
}
// Gateway/router providers routing to Anthropic (format: "provider:anthropic/model")
const [, modelId] = modelString.split(":");
// Other gateway/router providers routing to Anthropic (format: "provider:anthropic/model")
const [, modelId] = normalized.split(":");
if (modelId?.startsWith("anthropic/")) {
return true;
}
Expand Down
65 changes: 65 additions & 0 deletions src/common/utils/ai/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "bun:test";
import { normalizeGatewayModel, getModelName, supports1MContext } from "./models";

describe("normalizeGatewayModel", () => {
it("should convert mux-gateway:provider/model to provider:model", () => {
expect(normalizeGatewayModel("mux-gateway:anthropic/claude-opus-4-5")).toBe(
"anthropic:claude-opus-4-5"
);
expect(normalizeGatewayModel("mux-gateway:openai/gpt-4o")).toBe("openai:gpt-4o");
expect(normalizeGatewayModel("mux-gateway:google/gemini-2.5-pro")).toBe(
"google:gemini-2.5-pro"
);
});

it("should return non-gateway strings unchanged", () => {
expect(normalizeGatewayModel("anthropic:claude-opus-4-5")).toBe("anthropic:claude-opus-4-5");
expect(normalizeGatewayModel("openai:gpt-4o")).toBe("openai:gpt-4o");
expect(normalizeGatewayModel("claude-opus-4-5")).toBe("claude-opus-4-5");
});

it("should return malformed gateway strings unchanged", () => {
// No slash in the inner part
expect(normalizeGatewayModel("mux-gateway:no-slash-here")).toBe("mux-gateway:no-slash-here");
});
});

describe("getModelName", () => {
it("should extract model name from provider:model format", () => {
expect(getModelName("anthropic:claude-opus-4-5")).toBe("claude-opus-4-5");
expect(getModelName("openai:gpt-4o")).toBe("gpt-4o");
});

it("should handle mux-gateway format", () => {
expect(getModelName("mux-gateway:anthropic/claude-opus-4-5")).toBe("claude-opus-4-5");
expect(getModelName("mux-gateway:openai/gpt-4o")).toBe("gpt-4o");
});

it("should return full string if no colon", () => {
expect(getModelName("claude-opus-4-5")).toBe("claude-opus-4-5");
});
});

describe("supports1MContext", () => {
it("should return true for Anthropic Sonnet 4 models", () => {
expect(supports1MContext("anthropic:claude-sonnet-4-5")).toBe(true);
expect(supports1MContext("anthropic:claude-sonnet-4-5-20250514")).toBe(true);
expect(supports1MContext("anthropic:claude-sonnet-4-20250514")).toBe(true);
});

it("should return true for mux-gateway Sonnet 4 models", () => {
expect(supports1MContext("mux-gateway:anthropic/claude-sonnet-4-5")).toBe(true);
expect(supports1MContext("mux-gateway:anthropic/claude-sonnet-4-5-20250514")).toBe(true);
});

it("should return false for non-Anthropic models", () => {
expect(supports1MContext("openai:gpt-4o")).toBe(false);
expect(supports1MContext("mux-gateway:openai/gpt-4o")).toBe(false);
});

it("should return false for Anthropic non-Sonnet-4 models", () => {
expect(supports1MContext("anthropic:claude-opus-4-5")).toBe(false);
expect(supports1MContext("anthropic:claude-haiku-4-5")).toBe(false);
expect(supports1MContext("mux-gateway:anthropic/claude-opus-4-5")).toBe(false);
});
});
30 changes: 26 additions & 4 deletions src/common/utils/ai/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,38 @@ import { DEFAULT_MODEL } from "@/common/constants/knownModels";

export const defaultModel = DEFAULT_MODEL;

const MUX_GATEWAY_PREFIX = "mux-gateway:";

/**
* Normalize gateway-prefixed model strings to standard format.
* Converts "mux-gateway:provider/model" to "provider:model".
* Returns non-gateway strings unchanged.
*/
export function normalizeGatewayModel(modelString: string): string {
if (!modelString.startsWith(MUX_GATEWAY_PREFIX)) {
return modelString;
}
// mux-gateway:anthropic/claude-opus-4-5 → anthropic:claude-opus-4-5
const inner = modelString.slice(MUX_GATEWAY_PREFIX.length);
const slashIndex = inner.indexOf("/");
if (slashIndex === -1) {
return modelString; // Malformed, return as-is
}
return `${inner.slice(0, slashIndex)}:${inner.slice(slashIndex + 1)}`;
}

/**
* Extract the model name from a model string (e.g., "anthropic:claude-sonnet-4-5" -> "claude-sonnet-4-5")
* @param modelString - Full model string in format "provider:model-name"
* @returns The model name part (after the colon), or the full string if no colon is found
*/
export function getModelName(modelString: string): string {
const colonIndex = modelString.indexOf(":");
const normalized = normalizeGatewayModel(modelString);
const colonIndex = normalized.indexOf(":");
if (colonIndex === -1) {
return modelString;
return normalized;
}
return modelString.substring(colonIndex + 1);
return normalized.substring(colonIndex + 1);
}

/**
Expand All @@ -26,7 +47,8 @@ export function getModelName(modelString: string): string {
* @returns True if the model supports 1M context window
*/
export function supports1MContext(modelString: string): boolean {
const [provider, modelName] = modelString.split(":");
const normalized = normalizeGatewayModel(modelString);
const [provider, modelName] = normalized.split(":");
if (provider !== "anthropic") {
return false;
}
Expand Down
20 changes: 20 additions & 0 deletions src/common/utils/tokens/modelStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ describe("getModelStats", () => {
});
});

describe("mux-gateway models", () => {
test("should handle mux-gateway:anthropic/model format", () => {
const stats = getModelStats("mux-gateway:anthropic/claude-sonnet-4-5");
expect(stats).not.toBeNull();
expect(stats?.input_cost_per_token).toBe(0.000003);
expect(stats?.output_cost_per_token).toBe(0.000015);
});

test("should handle mux-gateway:openai/model format", () => {
const stats = getModelStats("mux-gateway:openai/gpt-4o");
expect(stats).not.toBeNull();
expect(stats?.max_input_tokens).toBeGreaterThan(0);
});

test("should return null for mux-gateway with unknown model", () => {
const stats = getModelStats("mux-gateway:anthropic/unknown-model-xyz");
expect(stats).toBeNull();
});
});

describe("model without provider prefix", () => {
test("should handle model string without provider", () => {
const stats = getModelStats("gpt-5.1");
Expand Down
4 changes: 3 additions & 1 deletion src/common/utils/tokens/modelStats.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import modelsData from "./models.json";
import { modelsExtra } from "./models-extra";
import { normalizeGatewayModel } from "../ai/models";

export interface ModelStats {
max_input_tokens: number;
Expand Down Expand Up @@ -92,7 +93,8 @@ function generateLookupKeys(modelString: string): string[] {
* @returns ModelStats or null if model not found
*/
export function getModelStats(modelString: string): ModelStats | null {
const lookupKeys = generateLookupKeys(modelString);
const normalized = normalizeGatewayModel(modelString);
const lookupKeys = generateLookupKeys(normalized);

// Try each lookup pattern in main models.json
for (const key of lookupKeys) {
Expand Down
6 changes: 4 additions & 2 deletions src/node/utils/main/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { CountTokensInput } from "./tokenizer.worker";
import { models, type ModelName } from "ai-tokenizer";
import { run } from "./workerPool";
import { TOKENIZER_MODEL_OVERRIDES, DEFAULT_WARM_MODELS } from "@/common/constants/knownModels";
import { normalizeGatewayModel } from "@/common/utils/ai/models";

/**
* Public tokenizer interface exposed to callers.
Expand Down Expand Up @@ -48,10 +49,11 @@ function normalizeModelKey(modelName: string): ModelName | null {
* Optionally logs a warning when falling back.
*/
function resolveModelName(modelString: string): ModelName {
let modelName = normalizeModelKey(modelString);
const normalized = normalizeGatewayModel(modelString);
let modelName = normalizeModelKey(normalized);

if (!modelName) {
const provider = modelString.split(":")[0] || "anthropic";
const provider = normalized.split(":")[0] || "anthropic";
const fallbackModel =
provider === "anthropic"
? "anthropic/claude-sonnet-4.5"
Expand Down