Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
97 changes: 97 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@
"knip": "^5.44.4",
"lint-staged": "^15.2.11",
"mkdirp": "^3.0.1",
"nock": "^14.0.4",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
Expand Down

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions src/api/providers/fetchers/__tests__/openrouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// npx jest src/api/providers/fetchers/__tests__/openrouter.test.ts

import path from "path"

import { back as nockBack } from "nock"

import { getOpenRouterModels, modelsSupportingPromptCache } from "../openrouter"

nockBack.fixtures = path.join(__dirname, "fixtures")
nockBack.setMode("dryrun")

describe("OpenRouter API", () => {
describe("getOpenRouterModels", () => {
it("fetches models and validates schema", async () => {
const { nockDone } = await nockBack("openrouter-models.json")

const models = await getOpenRouterModels()

expect(
Object.entries(models)
.filter(([_, model]) => model.supportsPromptCache)
.map(([id, _]) => id)
.sort(),
).toEqual(Array.from(modelsSupportingPromptCache).sort())

expect(
Object.entries(models)
.filter(([_, model]) => model.supportsComputerUse)
.map(([id, _]) => id)
.sort(),
).toEqual([
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-sonnet:beta",
"anthropic/claude-3.7-sonnet",
"anthropic/claude-3.7-sonnet:beta",
"anthropic/claude-3.7-sonnet:thinking",
])

expect(models["anthropic/claude-3.7-sonnet"]).toEqual({
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: expect.any(String),
thinking: false,
supportsComputerUse: true,
})

expect(models["anthropic/claude-3.7-sonnet:thinking"]).toEqual({
maxTokens: 128000,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: expect.any(String),
thinking: true,
supportsComputerUse: true,
})

nockDone()
})
})
})
135 changes: 135 additions & 0 deletions src/api/providers/fetchers/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import axios from "axios"
import { z } from "zod"

import { ApiHandlerOptions, ModelInfo } from "../../../shared/api"
import { parseApiPrice } from "../../../utils/cost"

// https://openrouter.ai/api/v1/models
export const openRouterModelSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
context_length: z.number(),
max_completion_tokens: z.number().nullish(),
architecture: z
.object({
modality: z.string().nullish(),
tokenizer: z.string().nullish(),
})
.optional(),
pricing: z
.object({
prompt: z.string().nullish(),
completion: z.string().nullish(),
input_cache_write: z.string().nullish(),
input_cache_read: z.string().nullish(),
})
.optional(),
top_provider: z
.object({
max_completion_tokens: z.number().nullish(),
})
.optional(),
})

export type OpenRouterModel = z.infer<typeof openRouterModelSchema>

const openRouterModelsResponseSchema = z.object({
data: z.array(openRouterModelSchema),
})

type OpenRouterModelsResponse = z.infer<typeof openRouterModelsResponseSchema>

export async function getOpenRouterModels(options?: ApiHandlerOptions) {
const models: Record<string, ModelInfo> = {}
const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1"

try {
const response = await axios.get<OpenRouterModelsResponse>(`${baseURL}/models`)
const result = openRouterModelsResponseSchema.safeParse(response.data)
const rawModels = result.success ? result.data.data : response.data.data

if (!result.success) {
console.error("OpenRouter models response is invalid", result.error.format())
}

for (const rawModel of rawModels) {
const cacheWritesPrice = rawModel.pricing?.input_cache_write
? parseApiPrice(rawModel.pricing?.input_cache_write)
: undefined

const cacheReadsPrice = rawModel.pricing?.input_cache_read
? parseApiPrice(rawModel.pricing?.input_cache_read)
: undefined

// Disable prompt caching for Gemini models for now.
const supportsPromptCache = !!cacheWritesPrice && !!cacheWritesPrice && !rawModel.id.startsWith("google")

const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache,
inputPrice: parseApiPrice(rawModel.pricing?.prompt),
outputPrice: parseApiPrice(rawModel.pricing?.completion),
cacheWritesPrice,
cacheReadsPrice,
description: rawModel.description,
thinking: rawModel.id === "anthropic/claude-3.7-sonnet:thinking",
}

// NOTE: This needs to be synced with api.ts/openrouter default model info.
switch (true) {
case rawModel.id.startsWith("anthropic/claude-3.7-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 128_000 : 8192
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.maxTokens = 8192
break
case rawModel.id.startsWith("anthropic/claude-3-5-haiku"):
case rawModel.id.startsWith("anthropic/claude-3-opus"):
case rawModel.id.startsWith("anthropic/claude-3-haiku"):
modelInfo.maxTokens = 8192
break
default:
break
}

models[rawModel.id] = modelInfo
}
} catch (error) {
console.error(
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
}

return models
}

export const modelsSupportingPromptCache = new Set([
"anthropic/claude-3-haiku",
"anthropic/claude-3-haiku:beta",
"anthropic/claude-3-opus",
"anthropic/claude-3-opus:beta",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-sonnet:beta",
"anthropic/claude-3.5-haiku",
"anthropic/claude-3.5-haiku-20241022",
"anthropic/claude-3.5-haiku-20241022:beta",
"anthropic/claude-3.5-haiku:beta",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-sonnet-20240620",
"anthropic/claude-3.5-sonnet-20240620:beta",
"anthropic/claude-3.5-sonnet:beta",
"anthropic/claude-3.7-sonnet",
"anthropic/claude-3.7-sonnet:beta",
"anthropic/claude-3.7-sonnet:thinking",
// "google/gemini-2.0-flash-001",
// "google/gemini-flash-1.5",
// "google/gemini-flash-1.5-8b",
])
Loading
Loading