Skip to content
Merged
25 changes: 25 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 @@ -407,6 +407,7 @@
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"sanitize-filename": "^1.6.3",
"say": "^0.16.0",
"serialize-error": "^11.0.3",
"simple-git": "^3.27.0",
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/__tests__/glama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GlamaHandler } from "../glama"
import { ApiHandlerOptions } from "../../../shared/api"

// Mock dependencies
jest.mock("../fetchers/cache", () => ({
jest.mock("../fetchers/modelCache", () => ({
getModels: jest.fn().mockImplementation(() => {
return Promise.resolve({
"anthropic/claude-3-7-sonnet": {
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/__tests__/openrouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ApiHandlerOptions } from "../../../shared/api"
// Mock dependencies
jest.mock("openai")
jest.mock("delay", () => jest.fn(() => Promise.resolve()))
jest.mock("../fetchers/cache", () => ({
jest.mock("../fetchers/modelCache", () => ({
getModels: jest.fn().mockImplementation(() => {
return Promise.resolve({
"anthropic/claude-3.7-sonnet": {
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/__tests__/requesty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ApiHandlerOptions } from "../../../shared/api"

jest.mock("openai")
jest.mock("delay", () => jest.fn(() => Promise.resolve()))
jest.mock("../fetchers/cache", () => ({
jest.mock("../fetchers/modelCache", () => ({
getModels: jest.fn().mockImplementation(() => {
return Promise.resolve({
"coding/claude-3-7-sonnet": {
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/__tests__/unbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ApiHandlerOptions } from "../../../shared/api"
import { UnboundHandler } from "../unbound"

// Mock dependencies
jest.mock("../fetchers/cache", () => ({
jest.mock("../fetchers/modelCache", () => ({
getModels: jest.fn().mockImplementation(() => {
return Promise.resolve({
"anthropic/claude-3-5-sonnet-20241022": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[
{
"scope": "https://openrouter.ai:443",
"method": "GET",
"path": "/api/v1/models/google/gemini-2.5-pro-preview/endpoints",
"body": "",
"status": 200,
"response": [
"31441d002056aa5ad5de6cfba09eb44cd983cf558aa50307224fd48d88f0c0d12137eda7bef1c435891ecc325645bf9d4794cd227137c069a7450a3f6ea3541aeacce9727170159a489e4b07a179ae738dc1a983bd860cb018631c277e3ab29720d5dea2ad528e551ef3c67c0e83e03cc3e22da9c6d2dbbb03ed2d5afa96237dbbe0d4e5e379806d0ef657edc161db2c0d863cfc7525951860c1af95425fdef6f1e177a1a24eb98a9b4ab75cb9acf4e63df938f044074a6c06dac44cda2750e3aa6e1246437d1cde032d10d0fceac4d20b07958df4a4aeec4affaa012d9b3eb5d0e3c33fdd4ad849181f1ffe53efd2b0f7f70b17431cdc7a92309228d5154e736588069b1ce7714bce6952e85c744b1cb672c175e424fda500d2300b1b3041bffe4209e02917760c1a225f6c218da952e14c3eaba01868e2fc07a68969cda1df7a9777e56ff7021bc945ab34b99e29c5222ab6214868114c9f3ebfc91c1c358cbac63aba3c18cabc99b8570923ed7b493445434205c506e4261983e7a03ac145e5e4177400cabf2a713a933092e58c0b18a4ecdf48b9d73933ec3534ee38c815670864c1a091d593757a991836ccd364e0e3e026d14b58285fe813f16ee4eaa5f285b20969d68ece56b8c01e61f98b7837320c3632314e0ce2acf4b627b7061c86ca07350aecd135c00ba71b0a08efaa5e567b2d0cbc9adc95fbb8146c53ef1fb6072b8394a59730c25e23e5e893c2a25ed4755dd70db7e0d3c42101aeda3430c89cb7df048b5a2990a64ddbac6070ceebeefc16f4f805e51cdcd44502b278439ab5eb5dbfe52eb31b84c8552f1b9aaaf32ccab7a459896918a4f4096b035bdf1a6cccc99db59ac1e0d7ec82ca95d307726386bbe8b4243aff7b14d855db2e5b0ad032c82ac88aecad09dd4eab813d6282a8dd0d947de2ecb0656ea03175e91d885361ba221b03605034261814e6c1c060c0125d58114a23c9334aa543079846052706459dce45f590e0f827bf794f3f751e24c224c06e3106cccf5c5dea93db5b0303"
],
"rawHeaders": {
"access-control-allow-origin": "*",
"cache-control": "s-maxage=300, stale-while-revalidate=600",
"cf-ray": "93ed496b8e0a0fb1-LAX",
"connection": "close",
"content-encoding": "br",
"content-type": "application/json",
"date": "Mon, 12 May 2025 22:17:32 GMT",
"server": "cloudflare",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding"
},
"responseIsBinary": false
}
]

Large diffs are not rendered by default.

42 changes: 39 additions & 3 deletions src/api/providers/fetchers/__tests__/openrouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import { back as nockBack } from "nock"

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

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

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

describe("OpenRouter API", () => {
describe.skip("OpenRouter API", () => {
describe("getOpenRouterModels", () => {
// This flakes in CI (probably related to Nock). Need to figure out why.
it.skip("fetches models and validates schema", async () => {
it("fetches models and validates schema", async () => {
const { nockDone } = await nockBack("openrouter-models.json")

const models = await getOpenRouterModels()
Expand Down Expand Up @@ -95,4 +95,40 @@ describe("OpenRouter API", () => {
nockDone()
})
})

describe("getOpenRouterModelEndpoints", () => {
it("fetches model endpoints and validates schema", async () => {
const { nockDone } = await nockBack("openrouter-model-endpoints.json")
const endpoints = await getOpenRouterModelEndpoints("google/gemini-2.5-pro-preview")

expect(endpoints).toEqual({
Google: {
maxTokens: 0,
contextWindow: 1048576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10,
cacheWritesPrice: 1.625,
cacheReadsPrice: 0.31,
description: undefined,
thinking: false,
},
"Google AI Studio": {
maxTokens: 0,
contextWindow: 1048576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10,
cacheWritesPrice: 1.625,
cacheReadsPrice: 0.31,
description: undefined,
thinking: false,
},
})

nockDone()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const getModels = async (
baseUrl: string | undefined = undefined,
): Promise<ModelRecord> => {
let models = memoryCache.get<ModelRecord>(router)

if (models) {
// console.log(`[getModels] NodeCache hit for ${router} -> ${Object.keys(models).length}`)
return models
Expand Down Expand Up @@ -82,15 +83,19 @@ export const getModels = async (
try {
await writeModels(router, models)
// console.log(`[getModels] wrote ${router} models to file cache`)
} catch (error) {}
} catch (error) {
console.error(`[getModels] error writing ${router} models to file cache`, error)
}

return models
}

try {
models = await readModels(router)
// console.log(`[getModels] read ${router} models from file cache`)
} catch (error) {}
} catch (error) {
console.error(`[getModels] error reading ${router} models from file cache`, error)
}

return models ?? {}
}
Expand Down
82 changes: 82 additions & 0 deletions src/api/providers/fetchers/modelEndpointCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as path from "path"
import fs from "fs/promises"

import NodeCache from "node-cache"
import sanitize from "sanitize-filename"

import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../shared/storagePathManager"
import { RouterName, ModelRecord } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"

import { getOpenRouterModelEndpoints } from "./openrouter"

const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })

const getCacheKey = (router: RouterName, modelId: string) => sanitize(`${router}_${modelId}`)

async function writeModelEndpoints(key: string, data: ModelRecord) {
const filename = `${key}_endpoints.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data, null, 2))
}

async function readModelEndpoints(key: string): Promise<ModelRecord | undefined> {
const filename = `${key}_endpoints.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
const filePath = path.join(cacheDir, filename)
const exists = await fileExistsAtPath(filePath)
return exists ? JSON.parse(await fs.readFile(filePath, "utf8")) : undefined
}

export const getModelEndpoints = async ({
router,
modelId,
endpoint,
}: {
router: RouterName
modelId?: string
endpoint?: string
}): Promise<ModelRecord> => {
// OpenRouter is the only provider that supports model endpoints, but you
// can see how we'd extend this to other providers in the future.
if (router !== "openrouter" || !modelId || !endpoint) {
return {}
}

const key = getCacheKey(router, modelId)
let modelProviders = memoryCache.get<ModelRecord>(key)

if (modelProviders) {
// console.log(`[getModelProviders] NodeCache hit for ${key} -> ${Object.keys(modelProviders).length}`)
return modelProviders
}

modelProviders = await getOpenRouterModelEndpoints(modelId)

if (Object.keys(modelProviders).length > 0) {
// console.log(`[getModelProviders] API fetch for ${key} -> ${Object.keys(modelProviders).length}`)
memoryCache.set(key, modelProviders)

try {
await writeModelEndpoints(key, modelProviders)
// console.log(`[getModelProviders] wrote ${key} endpoints to file cache`)
} catch (error) {
console.error(`[getModelProviders] error writing ${key} endpoints to file cache`, error)
}

return modelProviders
}

try {
modelProviders = await readModelEndpoints(router)
// console.log(`[getModelProviders] read ${key} endpoints from file cache`)
} catch (error) {
console.error(`[getModelProviders] error reading ${key} endpoints from file cache`, error)
}

return modelProviders ?? {}
}

export const flushModelProviders = async (router: RouterName, modelId: string) =>
memoryCache.del(getCacheKey(router, modelId))
Loading