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
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
1 change: 0 additions & 1 deletion src/api/providers/__tests__/openai.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { OpenAiHandler } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "../constants"

// Mock OpenAI client
const mockCreate = jest.fn()
Expand Down
1 change: 0 additions & 1 deletion src/api/providers/__tests__/openrouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ describe("OpenRouterHandler", () => {
info: mockOptions.openRouterModelInfo,
maxTokens: 1000,
reasoning: undefined,
supportsPromptCache: false,
temperature: 0,
thinking: undefined,
topP: undefined,
Expand Down
4 changes: 0 additions & 4 deletions src/api/providers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,3 @@ export const DEFAULT_HEADERS = {
export const ANTHROPIC_DEFAULT_MAX_TOKENS = 8192

export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.6

export const AZURE_AI_INFERENCE_PATH = "/models/chat/completions"

export const REASONING_MODELS = new Set(["x-ai/grok-3-mini-beta", "grok-3-mini-beta", "grok-3-mini-fast-beta"])

Large diffs are not rendered by default.

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

import path from "path"

import { back as nockBack } from "nock"

import { PROMPT_CACHING_MODELS } from "../../../../shared/api"

import { getOpenRouterModels } 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(PROMPT_CACHING_MODELS).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()
})
})
})
115 changes: 115 additions & 0 deletions src/api/providers/fetchers/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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 && !!cacheReadsPrice && !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",
}

// Then OpenRouter model definition doesn't give us any hints about computer use,
// so we need to set that manually.
// The ideal `maxTokens` values are model dependent, but we should probably DRY
// this up and use the values defined for the Anthropic providers.
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
}
4 changes: 3 additions & 1 deletion src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { convertToSimpleMessages } from "../transform/simple-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import { XmlMatcher } from "../../utils/xml-matcher"
import { DEEP_SEEK_DEFAULT_TEMPERATURE, DEFAULT_HEADERS, AZURE_AI_INFERENCE_PATH } from "./constants"
import { DEFAULT_HEADERS, DEEP_SEEK_DEFAULT_TEMPERATURE } from "./constants"

export const AZURE_AI_INFERENCE_PATH = "/models/chat/completions"

export interface OpenAiHandlerOptions extends ApiHandlerOptions {}

Expand Down
Loading