Skip to content
Open
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
53 changes: 44 additions & 9 deletions src/api/providers/fetchers/__tests__/modelCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@ describe("getModels with new GetModelsOptions", () => {
expect(result).toEqual(mockModels)
})

it("forwards the OpenRouter API key to getOpenRouterModels", async () => {
const mockModels = {
"openrouter/model": {
maxTokens: 8192,
contextWindow: 128000,
supportsPromptCache: false,
},
}
mockGetOpenRouterModels.mockResolvedValue(mockModels)

await getModels({ provider: providerIdentifiers.openrouter, apiKey: "openrouter-key" })

expect(mockGetOpenRouterModels).toHaveBeenCalledWith({
openRouterApiKey: "openrouter-key",
openRouterBaseUrl: undefined,
})
})

it("calls getRequestyModels with optional API key", async () => {
const mockModels = {
"requesty/model": {
Expand Down Expand Up @@ -1125,9 +1143,10 @@ describe("NanoGPT key-scoped cache isolation", () => {

describe("compound cache key derivation across scoping dimensions", () => {
// Exercises every branch of getCacheKey via the public getModels() entry point.
// litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare
// provider fallback. The fetcher mocks let us observe the cache key the result is
// written under (first arg of the matching memoryCache.set call).
// litellm is url-scoped AND key-scoped; openrouter is key-scoped only, so it hits the
// key discriminator branch (or the bare provider fallback when no key is supplied). The
// fetcher mocks let us observe the cache key the result is written under (first arg of
// the matching memoryCache.set call).
const mockModels = {
"compound/model": {
maxTokens: 4096,
Expand Down Expand Up @@ -1193,14 +1212,30 @@ describe("compound cache key derivation across scoping dimensions", () => {
expect(cacheKey).toBe("litellm:http://host:4000")
})

it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => {
await getModels({
provider: providerIdentifiers.openrouter,
apiKey: "ignored-key",
baseUrl: "http://ignored:4000",
})
it("falls back to the bare provider name for a key-scoped provider without an API key", async () => {
await getModels({ provider: providerIdentifiers.openrouter })
const cacheKey = writtenCacheKey()

expect(cacheKey).toBe("openrouter")
})

it("includes only the key discriminator for a key-scoped provider without a custom URL", async () => {
await getModels({ provider: providerIdentifiers.openrouter, apiKey: "openrouter-key" })
const cacheKey = writtenCacheKey()

expect(cacheKey).toMatch(/^openrouter:[0-9a-f]{8}$/)
})

it("writes different cache keys for two different OpenRouter API keys", async () => {
await getModels({ provider: providerIdentifiers.openrouter, apiKey: "key-one" })
const firstKey = writtenCacheKey()

mockSet.mockClear()
await getModels({ provider: providerIdentifiers.openrouter, apiKey: "key-two" })
const secondKey = writtenCacheKey()

expect(firstKey).toBeDefined()
expect(secondKey).toBeDefined()
expect(firstKey).not.toEqual(secondKey)
})
})
201 changes: 201 additions & 0 deletions src/api/providers/fetchers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,205 @@ describe("OpenRouter API", () => {
expect(resultWithoutTools.supportedParameters).toContain("max_tokens")
})
})

describe("getOpenRouterModels auth and private/preset models", () => {
it("omits the Authorization header and skips user/preset endpoints when no API key is provided", async () => {
const axios = await import("axios")
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: { data: [] } })

await getOpenRouterModels()

expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models"), { headers: undefined })
expect(getSpy).not.toHaveBeenCalledWith(expect.stringContaining("/models/user"), expect.anything())
expect(getSpy).not.toHaveBeenCalledWith(expect.stringContaining("/presets"), expect.anything())

getSpy.mockRestore()
})

it("sends the Authorization header to models, user models, and presets when a key is provided", async () => {
const axios = await import("axios")
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: { data: [] } })

await getOpenRouterModels({ openRouterApiKey: "test-key" })

const authHeader = expect.objectContaining({ headers: { Authorization: "Bearer test-key" } })

expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models"), authHeader)
expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models/user"), authHeader)
expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/presets"), authHeader)

getSpy.mockRestore()
})

it("merges public, user, and preset models into the returned record", async () => {
const publicModel = {
id: "openai/gpt-4o",
name: "GPT-4o",
context_length: 128000,
pricing: { prompt: "0.000005", completion: "0.000015" },
}
const userModel = {
id: "private/account-model",
name: "Account model",
context_length: 65536,
pricing: { prompt: "0", completion: "0" },
}

const axios = await import("axios")
const getSpy = vi
.spyOn(axios.default, "get")
.mockResolvedValueOnce({ data: { data: [publicModel] } })
.mockResolvedValueOnce({ data: { data: [userModel] } })
.mockResolvedValueOnce({
data: {
data: [
{
id: "preset-1",
name: "Flash",
slug: "flash",
description: null,
models: ["openai/gpt-4o"],
},
],
},
})

const models = await getOpenRouterModels({ openRouterApiKey: "test-key" })

expect(models["openai/gpt-4o"]).toBeDefined()
expect(models["private/account-model"]).toBeDefined()
const preset = models["@preset/flash"]
expect(preset).toBeDefined()

getSpy.mockRestore()
})

it("derives a single-model preset context window from its underlying model", async () => {
const publicModel = {
id: "openai/gpt-4o",
name: "GPT-4o",
context_length: 128000,
pricing: { prompt: "0.000005", completion: "0.000015" },
}

const axios = await import("axios")
const getSpy = vi
.spyOn(axios.default, "get")
.mockResolvedValueOnce({ data: { data: [publicModel] } })
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({
data: {
data: [
{
id: "preset-1",
name: "Flash",
slug: "flash",
description: null,
models: ["openai/gpt-4o"],
},
],
},
})

const models = await getOpenRouterModels({ openRouterApiKey: "test-key" })

const preset = models["@preset/flash"]
expect(preset).toBeDefined()
expect(preset).not.toHaveProperty("contextWindow")
expect(preset).not.toHaveProperty("description")
expect(preset?.supportsPromptCache).toBe(false)

getSpy.mockRestore()
})

it("derives a multi-model preset context window as the max across its models", async () => {
const smallModel = {
id: "openai/gpt-4o-mini",
name: "GPT-4o mini",
context_length: 128000,
pricing: { prompt: "0.00000015", completion: "0.0000006" },
}
const largeModel = {
id: "anthropic/claude-3.7-sonnet",
name: "Claude 3.7 Sonnet",
context_length: 200000,
pricing: { prompt: "0.000003", completion: "0.000015" },
}

const axios = await import("axios")
const getSpy = vi
.spyOn(axios.default, "get")
.mockResolvedValueOnce({ data: { data: [smallModel, largeModel] } })
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({
data: {
data: [
{
id: "preset-1",
name: "Mixed",
slug: "mixed",
description: null,
models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"],
},
],
},
})

const models = await getOpenRouterModels({ openRouterApiKey: "test-key" })

const preset = models["@preset/mixed"]
expect(preset).toBeDefined()
expect(preset).not.toHaveProperty("contextWindow")
expect(preset).not.toHaveProperty("description")
expect(preset?.supportsPromptCache).toBe(false)

getSpy.mockRestore()
})

it("falls back to a conservative context window when a preset's models cannot be resolved", async () => {
const axios = await import("axios")
const getSpy = vi
.spyOn(axios.default, "get")
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({ data: { data: [] } })
.mockResolvedValueOnce({
data: {
data: [
{
id: "preset-1",
name: "Orphan",
slug: "orphan",
description: null,
models: ["unknown/model-not-in-list"],
},
],
},
})

const models = await getOpenRouterModels({ openRouterApiKey: "test-key" })

const preset = models["@preset/orphan"]
expect(preset).toBeDefined()
expect(preset).not.toHaveProperty("contextWindow")
expect(preset).not.toHaveProperty("description")
expect(preset?.supportsPromptCache).toBe(false)

getSpy.mockRestore()
})
})

describe("parseOpenRouterModel accepts preset ids", () => {
it("parses an @preset/flash id without rejecting the @ prefix", () => {
const result = parseOpenRouterModel({
id: "@preset/flash",
model: { name: "Flash preset", context_length: 200000 },
inputModality: ["text"],
outputModality: ["text"],
maxTokens: undefined,
})

expect(result.contextWindow).toBe(200000)
expect(result.supportsPromptCache).toBe(false)
})
})
})
6 changes: 5 additions & 1 deletion src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set([
// identity -- see the URL_SCOPED_PROVIDERS comment above for why this matters despite caching
// being skipped for both.
const KEY_SCOPED_PROVIDERS: ReadonlySet<RouterName> = new Set([
providerIdentifiers.openrouter, // Per-key private/preset models (e.g. @preset/*)
Comment thread
frkr marked this conversation as resolved.
providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature
providerIdentifiers.poe, // Per-account model availability
providerIdentifiers.requesty, // Per-account custom model policies
Expand Down Expand Up @@ -228,7 +229,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model

switch (provider) {
case providerIdentifiers.openrouter:
models = await getOpenRouterModels()
models = await getOpenRouterModels({
openRouterApiKey: options.apiKey,
openRouterBaseUrl: options.baseUrl,
})
break
case providerIdentifiers.requesty:
// Requesty models endpoint requires an API key for per-user custom policies.
Expand Down
Loading
Loading