diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8275a5f3de..6e9e1314e9 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -100,6 +100,9 @@ export function buildOpenAIChatPassthroughRequest( if (rawBody[field] !== undefined) body[field] = rawBody[field]; } + const openRouterRouting = resolveOpenRouterRouting(provider, modelId); + if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); + if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; if (modelInList(provider.noPenaltyModels, modelId)) { diff --git a/tests/openrouter-provider-routing.test.ts b/tests/openrouter-provider-routing.test.ts index 4d98275d0f..b6ee368f3f 100644 --- a/tests/openrouter-provider-routing.test.ts +++ b/tests/openrouter-provider-routing.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { openRouterRoutingConfigError, openRouterProviderPayload, } from "../src/providers/openrouter-routing"; +import { clearKeyCooldowns, rotateProviderTransportOn429 } from "../src/providers/key-failover"; import { routeModel } from "../src/router"; import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; function provider(baseUrl: string, overrides: Partial = {}): OcxProviderConfig { return { adapter: "openai-chat", baseUrl, apiKey: "test-key", ...overrides }; @@ -26,6 +31,18 @@ function body(baseUrl: string, modelId: string, overrides: Partial; } +function passthroughBody( + providerConfig: OcxProviderConfig, + modelId: string, + rawBody: Record = {}, +): Record { + const request = buildOpenAIChatPassthroughRequest(providerConfig, { + messages: [{ role: "user", content: "hello" }], + ...rawBody, + }, modelId, false); + return JSON.parse(request.body as string) as Record; +} + describe("OpenRouter configurable provider routing", () => { const deepSeekLock = { openRouterRouting: { only: ["deepseek"], allowFallbacks: false } }; @@ -100,6 +117,91 @@ describe("OpenRouter configurable provider routing", () => { expect(requestBody.stream_options).toEqual({ include_usage: true }); }); + test("preserves exact model routing on native Chat passthrough requests", () => { + const requestBody = passthroughBody(provider("https://openrouter.ai/api/v1", { + openRouterRouting: { order: ["deepseek"], allowFallbacks: true }, + modelOpenRouterRouting: { + "anthropic/claude-sonnet-5": { only: ["anthropic"], allowFallbacks: false }, + }, + }), "anthropic/claude-sonnet-5", { + provider: { only: ["caller-controlled"] }, + }); + + expect(requestBody.provider).toEqual({ + only: ["anthropic"], allow_fallbacks: false, + }); + }); + + test("preserves provider-wide routing on native Chat passthrough requests", () => { + expect(passthroughBody( + provider("https://openrouter.ai/api/v1", deepSeekLock), + "deepseek/deepseek-chat", + ).provider).toEqual({ only: ["deepseek"], allow_fallbacks: false }); + }); + + test("resolves routed aliases before applying native Chat model preferences", () => { + const nativeModelId = "anthropic/claude-sonnet-5"; + const config: OcxConfig = { + port: 10100, + defaultProvider: "openrouter", + providers: { + openrouter: provider("https://openrouter.ai/api/v1", { + models: [nativeModelId], + openRouterRouting: { only: ["deepseek"] }, + modelOpenRouterRouting: { + [nativeModelId]: { only: ["anthropic"], allowFallbacks: false }, + }, + }), + }, + }; + const route = routeModel(config, "openrouter/anthropic-claude-sonnet-5"); + expect(route.modelId).toBe(nativeModelId); + expect(passthroughBody(route.provider, route.modelId).provider).toEqual({ + only: ["anthropic"], allow_fallbacks: false, + }); + }); + + test("does not forward a caller provider object to non-OpenRouter passthroughs", () => { + expect(passthroughBody( + provider("https://api.deepseek.com/v1"), + "deepseek-chat", + { provider: { only: ["caller-controlled"] } }, + ).provider).toBeUndefined(); + }); + + test("preserves provider routing after native Chat key rotation", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-openrouter-routing-")); + process.env.OPENCODEX_HOME = home; + clearKeyCooldowns("openrouter"); + const openrouter = provider("https://openrouter.ai/api/v1", { + authMode: "key", + apiKey: "key-one", + apiKeyPool: [{ id: "one", key: "key-one" }, { id: "two", key: "key-two" }], + openRouterRouting: { only: ["anthropic"], allowFallbacks: false }, + }); + const config: OcxConfig = { + port: 10100, + defaultProvider: "openrouter", + providers: { openrouter }, + }; + try { + const rotated = rotateProviderTransportOn429(config, "openrouter", openrouter, { + attemptedKey: "key-one", + now: 1_000_000, + }); + expect(rotated?.apiKey).toBe("key-two"); + expect(passthroughBody(rotated!, "anthropic/claude-sonnet-5").provider).toEqual({ + only: ["anthropic"], allow_fallbacks: false, + }); + } finally { + clearKeyCooldowns("openrouter"); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + test.each([ ["https://openrouter.ai/api/v1", "anthropic/claude-sonnet-5", {}], ["https://api.deepseek.com/v1", "deepseek-chat", deepSeekLock],