Skip to content

Commit 45b9778

Browse files
committed
Improve OpenRouter model fetching
1 parent 31600ed commit 45b9778

File tree

7 files changed

+340
-102
lines changed

7 files changed

+340
-102
lines changed

package-lock.json

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@
485485
"knip": "^5.44.4",
486486
"lint-staged": "^15.2.11",
487487
"mkdirp": "^3.0.1",
488+
"nock": "^14.0.4",
488489
"npm-run-all": "^4.1.5",
489490
"prettier": "^3.4.2",
490491
"rimraf": "^6.0.1",

src/api/providers/fetchers/__tests__/fixtures/openrouter-models.json

Lines changed: 25 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// npx jest src/api/providers/fetchers/__tests__/openrouter.test.ts
2+
3+
import path from "path"
4+
5+
import { back as nockBack } from "nock"
6+
7+
import { getOpenRouterModels } from "../openrouter"
8+
9+
nockBack.fixtures = path.join(__dirname, "fixtures")
10+
nockBack.setMode("dryrun")
11+
12+
describe("OpenRouter API", () => {
13+
describe("getOpenRouterModels", () => {
14+
it("fetches models and validates schema", async () => {
15+
const { nockDone } = await nockBack("openrouter-models.json")
16+
17+
const models = await getOpenRouterModels()
18+
19+
const modelsSupportingPromptCache = Object.entries(models)
20+
.filter(([_, model]) => model.supportsPromptCache)
21+
.map(([id, _]) => id)
22+
.sort()
23+
24+
expect(modelsSupportingPromptCache).toEqual([
25+
"anthropic/claude-3-haiku",
26+
"anthropic/claude-3-haiku:beta",
27+
"anthropic/claude-3-opus",
28+
"anthropic/claude-3-opus:beta",
29+
"anthropic/claude-3-sonnet",
30+
"anthropic/claude-3-sonnet:beta",
31+
"anthropic/claude-3.5-haiku",
32+
"anthropic/claude-3.5-haiku-20241022",
33+
"anthropic/claude-3.5-haiku-20241022:beta",
34+
"anthropic/claude-3.5-haiku:beta",
35+
"anthropic/claude-3.5-sonnet",
36+
"anthropic/claude-3.5-sonnet-20240620",
37+
"anthropic/claude-3.5-sonnet-20240620:beta",
38+
"anthropic/claude-3.5-sonnet:beta",
39+
"anthropic/claude-3.7-sonnet",
40+
"anthropic/claude-3.7-sonnet:beta",
41+
"anthropic/claude-3.7-sonnet:thinking",
42+
"google/gemini-2.0-flash-001",
43+
"google/gemini-flash-1.5",
44+
"google/gemini-flash-1.5-8b",
45+
])
46+
47+
const modelsSupportingComputerUse = Object.entries(models)
48+
.filter(([_, model]) => model.supportsComputerUse)
49+
.map(([id, _]) => id)
50+
.sort()
51+
52+
expect(modelsSupportingComputerUse).toEqual([
53+
"anthropic/claude-3.5-sonnet",
54+
"anthropic/claude-3.5-sonnet:beta",
55+
"anthropic/claude-3.7-sonnet",
56+
"anthropic/claude-3.7-sonnet:beta",
57+
"anthropic/claude-3.7-sonnet:thinking",
58+
])
59+
60+
expect(models["anthropic/claude-3.7-sonnet"]).toEqual({
61+
maxTokens: 8192,
62+
contextWindow: 200000,
63+
supportsImages: true,
64+
supportsPromptCache: true,
65+
inputPrice: 3,
66+
outputPrice: 15,
67+
cacheWritesPrice: 3.75,
68+
cacheReadsPrice: 0.3,
69+
description: expect.any(String),
70+
thinking: false,
71+
supportsComputerUse: true,
72+
})
73+
74+
expect(models["anthropic/claude-3.7-sonnet:thinking"]).toEqual({
75+
maxTokens: 128000,
76+
contextWindow: 200000,
77+
supportsImages: true,
78+
supportsPromptCache: true,
79+
inputPrice: 3,
80+
outputPrice: 15,
81+
cacheWritesPrice: 3.75,
82+
cacheReadsPrice: 0.3,
83+
description: expect.any(String),
84+
thinking: true,
85+
supportsComputerUse: true,
86+
})
87+
88+
nockDone()
89+
})
90+
})
91+
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import axios from "axios"
2+
import { z } from "zod"
3+
4+
import { ApiHandlerOptions, ModelInfo } from "../../../shared/api"
5+
import { parseApiPrice } from "../../../utils/cost"
6+
7+
// https://openrouter.ai/api/v1/models
8+
export const openRouterModelSchema = z.object({
9+
id: z.string(),
10+
name: z.string(),
11+
description: z.string().optional(),
12+
context_length: z.number(),
13+
max_completion_tokens: z.number().nullish(),
14+
architecture: z
15+
.object({
16+
modality: z.string().nullish(),
17+
tokenizer: z.string().nullish(),
18+
})
19+
.optional(),
20+
pricing: z
21+
.object({
22+
prompt: z.string().nullish(),
23+
completion: z.string().nullish(),
24+
input_cache_write: z.string().nullish(),
25+
input_cache_read: z.string().nullish(),
26+
})
27+
.optional(),
28+
top_provider: z
29+
.object({
30+
max_completion_tokens: z.number().nullish(),
31+
})
32+
.optional(),
33+
})
34+
35+
export type OpenRouterModel = z.infer<typeof openRouterModelSchema>
36+
37+
const openRouterModelsResponseSchema = z.object({
38+
data: z.array(openRouterModelSchema),
39+
})
40+
41+
type OpenRouterModelsResponse = z.infer<typeof openRouterModelsResponseSchema>
42+
43+
export async function getOpenRouterModels(options?: ApiHandlerOptions) {
44+
const models: Record<string, ModelInfo> = {}
45+
const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1"
46+
47+
try {
48+
const response = await axios.get<OpenRouterModelsResponse>(`${baseURL}/models`)
49+
const result = openRouterModelsResponseSchema.safeParse(response.data)
50+
const rawModels = result.success ? result.data.data : response.data.data
51+
52+
if (!result.success) {
53+
console.error("OpenRouter models response is invalid", result.error.format())
54+
}
55+
56+
for (const rawModel of rawModels) {
57+
const cacheWritesPrice = rawModel.pricing?.input_cache_write
58+
? parseApiPrice(rawModel.pricing?.input_cache_write)
59+
: undefined
60+
61+
const cacheReadsPrice = rawModel.pricing?.input_cache_read
62+
? parseApiPrice(rawModel.pricing?.input_cache_read)
63+
: undefined
64+
65+
const supportsPromptCache = !!cacheWritesPrice && !!cacheWritesPrice
66+
67+
const modelInfo: ModelInfo = {
68+
maxTokens: rawModel.top_provider?.max_completion_tokens,
69+
contextWindow: rawModel.context_length,
70+
supportsImages: rawModel.architecture?.modality?.includes("image"),
71+
supportsPromptCache,
72+
inputPrice: parseApiPrice(rawModel.pricing?.prompt),
73+
outputPrice: parseApiPrice(rawModel.pricing?.completion),
74+
cacheWritesPrice,
75+
cacheReadsPrice,
76+
description: rawModel.description,
77+
thinking: rawModel.id === "anthropic/claude-3.7-sonnet:thinking",
78+
}
79+
80+
// NOTE: This needs to be synced with api.ts/openrouter default model info.
81+
switch (true) {
82+
case rawModel.id.startsWith("anthropic/claude-3.7-sonnet"):
83+
modelInfo.supportsComputerUse = true
84+
modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 128_000 : 8192
85+
break
86+
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
87+
modelInfo.maxTokens = 8192
88+
break
89+
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet"):
90+
modelInfo.supportsComputerUse = true
91+
modelInfo.maxTokens = 8192
92+
break
93+
case rawModel.id.startsWith("anthropic/claude-3-5-haiku"):
94+
case rawModel.id.startsWith("anthropic/claude-3-opus"):
95+
case rawModel.id.startsWith("anthropic/claude-3-haiku"):
96+
modelInfo.maxTokens = 8192
97+
break
98+
default:
99+
break
100+
}
101+
102+
models[rawModel.id] = modelInfo
103+
}
104+
} catch (error) {
105+
console.error(
106+
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
107+
)
108+
}
109+
110+
return models
111+
}

0 commit comments

Comments
 (0)