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
22 changes: 14 additions & 8 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ const DEFAULT_COST = {
cache_write: 0,
};

type Protocol = "openai" | "anthropic";

// Providers we explicitly know how to route. Models from any provider not
// listed here are skipped. Adding a provider is a one-line change here.
const PROVIDERS = new Map<string, { protocol: Protocol }>([
["openai", { protocol: "openai" }],
["anthropic", { protocol: "anthropic" }],
["deepseek", { protocol: "anthropic" }],
]);

type ModelDevEntry = {
id?: string;
name?: string;
Expand Down Expand Up @@ -97,10 +107,9 @@ export type ConfigModel = {

function buildLookupMap(modelsDevData: ModelsDevData) {
const byFullId = new Map<string, ModelDevEntry>();
const allowedProviders = new Set(["openai", "anthropic", "deepseek"]);

for (const [provider, providerData] of Object.entries(modelsDevData)) {
if (!allowedProviders.has(provider) || !providerData?.models) continue;
if (!PROVIDERS.has(provider) || !providerData?.models) continue;
for (const [modelId, entry] of Object.entries(providerData.models)) {
byFullId.set(`${provider}/${modelId}`, entry);
}
Expand Down Expand Up @@ -156,10 +165,6 @@ export async function fetchHubModels(): Promise<HubResponse> {
return res.json() as Promise<HubResponse>;
}

function usesAnthropicApi(provider: string): boolean {
return provider === "anthropic" || provider === "deepseek";
}

export function buildConfigModels(
modelsDevData: ModelsDevData,
hubData: HubResponse,
Expand All @@ -169,9 +174,10 @@ export function buildConfigModels(
const warnings: string[] = [];

for (const [provider, providerData] of Object.entries(hubData.providers)) {
if (!providerData?.models) continue;
const known = PROVIDERS.get(provider);
if (!known || !providerData?.models) continue;

const anthropic = usesAnthropicApi(provider);
const anthropic = known.protocol === "anthropic";

for (const [modelId, hubModel] of Object.entries(providerData.models)) {
const entry = resolveEntry(provider, modelId, byFullId);
Expand Down
28 changes: 14 additions & 14 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ const MODELS_DEV_DATA = {
name: "GPT-5.4 Nano",
limit: { context: 400000, output: 128000 },
attachment: true,
reasoning: false,
temperature: true,
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image", "audio"],
input: ["text", "image"],
output: ["text"],
},
cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 },
Expand Down Expand Up @@ -70,8 +70,8 @@ const MODELS_DEV_DATA = {
"deepseek-v4-pro": {
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
limit: { context: 128000, output: 32000 },
attachment: true,
limit: { context: 1000000, output: 384000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand All @@ -84,8 +84,8 @@ const MODELS_DEV_DATA = {
"deepseek-v4-flash": {
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
limit: { context: 128000, output: 32000 },
attachment: true,
limit: { context: 1000000, output: 384000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand Down Expand Up @@ -182,10 +182,10 @@ describe("config hook", () => {
npm: "@ai-sdk/openai",
},
attachment: true,
reasoning: false,
temperature: true,
reasoning: true,
temperature: false,
tool_call: true,
modalities: { input: ["text", "image", "audio"], output: ["text"] },
modalities: { input: ["text", "image"], output: ["text"] },
cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 },
limit: { context: 400000, output: 128000 },
interleaved: true,
Expand Down Expand Up @@ -220,13 +220,13 @@ describe("config hook", () => {
api: "https://hub.coreinfra.ai/claude/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
cost: { input: 1.5, output: 6, cache_read: 0.15, cache_write: 1.5 },
limit: { context: 128000, output: 32000 },
limit: { context: 1000000, output: 384000 },
interleaved: { field: "reasoning_content" },
headers: {
"anthropic-beta":
Expand All @@ -241,13 +241,13 @@ describe("config hook", () => {
api: "https://hub.coreinfra.ai/claude/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
cost: { input: 0.15, output: 0.6, cache_read: 0.015, cache_write: 0.15 },
limit: { context: 128000, output: 32000 },
limit: { context: 1000000, output: 384000 },
interleaved: { field: "reasoning_content" },
headers: {
"anthropic-beta":
Expand Down
89 changes: 59 additions & 30 deletions test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ const MODELS_DEV_FIXTURE = {
name: "GPT-5.4 Nano",
limit: { context: 400000, output: 128000 },
attachment: true,
reasoning: false,
temperature: true,
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image", "audio", "video", "pdf"],
input: ["text", "image"],
output: ["text"],
},
cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 },
Expand Down Expand Up @@ -57,8 +57,8 @@ const MODELS_DEV_FIXTURE = {
"deepseek-v4-pro": {
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
limit: { context: 128000, output: 32000 },
attachment: true,
limit: { context: 1000000, output: 384000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand All @@ -71,8 +71,8 @@ const MODELS_DEV_FIXTURE = {
"deepseek-v4-flash": {
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
limit: { context: 128000, output: 32000 },
attachment: true,
limit: { context: 1000000, output: 384000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand Down Expand Up @@ -131,11 +131,11 @@ describe("buildConfigModels", () => {
npm: "@ai-sdk/openai",
},
attachment: true,
reasoning: false,
temperature: true,
reasoning: true,
temperature: false,
tool_call: true,
modalities: {
input: ["text", "image", "audio", "video", "pdf"],
input: ["text", "image"],
output: ["text"],
},
cost: { input: 0.2, output: 1.25, cache_read: 0.02, cache_write: 0 },
Expand Down Expand Up @@ -177,7 +177,7 @@ describe("buildConfigModels", () => {
api: "https://hub.coreinfra.ai/claude/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand All @@ -186,7 +186,7 @@ describe("buildConfigModels", () => {
output: ["text"],
},
cost: { input: 1.5, output: 6, cache_read: 0.15, cache_write: 1.5 },
limit: { context: 128000, output: 32000 },
limit: { context: 1000000, output: 384000 },
interleaved: { field: "reasoning_content" },
headers: {
"anthropic-beta":
Expand All @@ -202,7 +202,7 @@ describe("buildConfigModels", () => {
api: "https://hub.coreinfra.ai/claude/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
Expand All @@ -211,7 +211,7 @@ describe("buildConfigModels", () => {
output: ["text"],
},
cost: { input: 0.15, output: 0.6, cache_read: 0.015, cache_write: 0.15 },
limit: { context: 128000, output: 32000 },
limit: { context: 1000000, output: 384000 },
interleaved: { field: "reasoning_content" },
headers: {
"anthropic-beta":
Expand Down Expand Up @@ -290,36 +290,65 @@ describe("buildConfigModels", () => {
expect(models["shared-id"].limit.context).toBe(100000);
});

it("does not resolve from non-allowed providers in models.dev", () => {
const modelsDevData = {
"provider-a": {
models: {
"some-model": {
id: "some-model",
name: "Should Not Match",
cost: { input: 99 },
it("skips unknown hub providers silently", () => {
const hubData = {
providers: {
google: {
models: {
"gemini-3-pro": { display_name: "Gemini 3 Pro" },
},
},
},
};

const { models, warnings } = buildConfigModels(MODELS_DEV_FIXTURE, hubData);

expect(models).toEqual({});
expect(warnings).toEqual([]);
});

it("skips providers named like inherited object properties", () => {
const hubData = {
providers: {
toString: {
models: {
"evil-model": { display_name: "Evil Model" },
},
},
constructor: {
models: {
"ctor-model": { display_name: "Ctor Model" },
},
},
},
};

const { models, warnings } = buildConfigModels(MODELS_DEV_FIXTURE, hubData);

expect(models).toEqual({});
expect(warnings).toEqual([]);
});

it("registers only known providers when hub mixes known and unknown", () => {
const hubData = {
providers: {
"provider-a": {
openai: {
models: {
"gpt-5.4-nano": { display_name: "GPT-5.4 Nano" },
},
},
google: {
models: {
"some-model": { display_name: "Hub Model" },
"gemini-3-pro": { display_name: "Gemini 3 Pro" },
},
},
},
};

const { models, warnings } = buildConfigModels(modelsDevData, hubData);
const { models, warnings } = buildConfigModels(MODELS_DEV_FIXTURE, hubData);

expect(warnings).toEqual([
"provider-a/some-model not found in models.dev — using defaults",
]);
expect(models["some-model"].name).toBe("Hub Model");
expect(models["some-model"].cost.input).toBe(0);
expect(Object.keys(models)).toEqual(["gpt-5.4-nano"]);
expect(warnings).toEqual([]);
});

it("uses hub display_name as fallback when models.dev entry has no name", () => {
Expand Down