diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts index d41d89b6..5b81b53d 100644 --- a/src/proxy/upstream-url.ts +++ b/src/proxy/upstream-url.ts @@ -9,6 +9,11 @@ function ensureSupportedProtocol(parsed: URL): void { } } +function isOpenRouterHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === 'openrouter.ai' || normalized.endsWith('.openrouter.ai'); +} + function buildResolvedUrl(baseUrl: string, suffix: string): string { const parsed = new URL(baseUrl); ensureSupportedProtocol(parsed); @@ -18,6 +23,11 @@ function buildResolvedUrl(baseUrl: string, suffix: string): string { return parsed.toString(); } + if (isOpenRouterHost(parsed.hostname) && pathname.endsWith('/api')) { + parsed.pathname = `${pathname}/v1${suffix}`; + return parsed.toString(); + } + if (pathname.endsWith('/v1') || pathname.endsWith('/api')) { parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`; return parsed.toString(); diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index b94fe6e8..5d934789 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -49,7 +49,7 @@ export interface ProviderPresetDefinition { icon?: string; } -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; /** * Legacy aliases mapped to canonical preset IDs. diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 0416c321..5a497d98 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -134,7 +134,7 @@ describe('profile-writer Anthropic direct', () => { it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', - 'https://openrouter.ai/api', + 'https://openrouter.ai/api/v1', 'sk-or-testkey', { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } ); @@ -145,7 +145,7 @@ describe('profile-writer Anthropic direct', () => { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank - expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api/v1'); expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index e672b496..8d2dc167 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -92,6 +92,11 @@ describe('provider-presets', () => { expect(isValidPresetId('hf')).toBe(true); }); + it('uses OpenRouter v1 as the OpenAI-compatible API root', () => { + const preset = getPresetById('openrouter'); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1'); + }); + it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' diff --git a/tests/unit/proxy/upstream-url.test.ts b/tests/unit/proxy/upstream-url.test.ts new file mode 100644 index 00000000..4776ea87 --- /dev/null +++ b/tests/unit/proxy/upstream-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; +import { + resolveOpenAIChatCompletionsUrl, + resolveOpenAIModelsUrl, +} from '../../../src/proxy/upstream-url'; + +describe('OpenAI-compatible upstream URL resolution', () => { + it('routes current OpenRouter API roots through /api/v1', () => { + expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api/v1')).toBe( + 'https://openrouter.ai/api/v1/chat/completions' + ); + expect(resolveOpenAIModelsUrl('https://openrouter.ai/api/v1')).toBe( + 'https://openrouter.ai/api/v1/models' + ); + }); + + it('repairs legacy OpenRouter /api roots before appending OpenAI endpoints', () => { + expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api')).toBe( + 'https://openrouter.ai/api/v1/chat/completions' + ); + expect(resolveOpenAIModelsUrl('https://openrouter.ai/api')).toBe( + 'https://openrouter.ai/api/v1/models' + ); + }); + + it('does not rewrite non-OpenRouter /api roots', () => { + expect(resolveOpenAIChatCompletionsUrl('https://example.test/api')).toBe( + 'https://example.test/api/chat/completions' + ); + }); +});