fix(openrouter): classify OpenRouter by exact hostname, not URL substring (#3979)

Substring matching treated custom gateways whose path or lookalike host
contained openrouter.ai as OpenRouter, sending models/usage that strict
OpenAI-compatible endpoints reject with 400.

Gate both the request body and session.endpointClass on hostname equality.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
This commit is contained in:
Alex Newman
2026-09-10 18:58:07 -07:00
committed by GitHub
parent 2efe8a0bae
commit f09a58631d
2 changed files with 80 additions and 2 deletions
+18 -2
View File
@@ -260,6 +260,22 @@ export function normalizeOpenRouterModel(rawModel: unknown): { model: string; fa
return { model: unique[0], fallbackModels: unique.slice(1) };
}
/**
* True only when the URL hostname is exactly `openrouter.ai`.
*
* Path text and lookalike hosts must not inherit OpenRouter-only body fields
* (`models`, `usage`) — strict OpenAI-compatible gateways 400 on those.
* Malformed URLs fail closed (treat as non-OpenRouter). Shared by the request
* body and `session.endpointClass` so the two sites cannot drift.
*/
export function isOpenRouterApiUrl(apiUrl: string): boolean {
try {
return new URL(apiUrl).hostname.toLowerCase() === 'openrouter.ai';
} catch {
return false;
}
}
/**
* Build the chat-completions request body.
*
@@ -280,7 +296,7 @@ export function buildOpenRouterRequestBody(input: {
messages: OpenAIMessage[];
apiUrl: string;
}): Record<string, unknown> {
const isOpenRouter = input.apiUrl.includes('openrouter.ai');
const isOpenRouter = isOpenRouterApiUrl(input.apiUrl);
const useFallbacks = isOpenRouter && input.fallbackModels.length > 0;
return {
...(useFallbacks
@@ -386,7 +402,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
protected prepareSessionExtras(session: ActiveSession, config: OpenRouterConfig): void {
// openrouter.ai responses carry real usage/cost; custom OpenAI-compatible
// gateways often fabricate or omit usage — let telemetry segment the two.
session.endpointClass = config.apiUrl.includes('openrouter.ai') ? 'openrouter' : 'custom';
session.endpointClass = isOpenRouterApiUrl(config.apiUrl) ? 'openrouter' : 'custom';
}
protected estimateTokens(text: string): number {
@@ -6,6 +6,7 @@ import { tmpdir } from 'os';
import { join } from 'path';
import {
buildOpenRouterRequestBody,
isOpenRouterApiUrl,
resolveOpenRouterConfig,
} from '../../src/services/worker/OpenRouterProvider.js';
import { DEFAULT_OPENROUTER_API_URL } from '../../src/shared/openrouter-base-url.js';
@@ -145,6 +146,54 @@ describe('buildOpenRouterRequestBody', () => {
expect(elsewhere).not.toHaveProperty('usage');
});
it('sends OpenRouter fields for https://openrouter.ai/... when fallbacks exist', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b'],
messages: MESSAGES,
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
});
expect(body.models).toEqual(['vendor/model-a', 'vendor/model-b']);
expect(body.usage).toEqual({ include: true });
expect(body).not.toHaveProperty('model');
});
it('treats a path containing openrouter.ai on a different host as a custom gateway', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b'],
messages: MESSAGES,
apiUrl: 'https://gateway.example.com/proxy/openrouter.ai/v1/chat/completions',
});
expect(body.model).toBe('vendor/model-a');
expect(body).not.toHaveProperty('models');
expect(body).not.toHaveProperty('usage');
});
it('treats a lookalike hostname as a custom gateway', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b'],
messages: MESSAGES,
apiUrl: 'https://openrouter.ai.evil.example/v1/chat/completions',
});
expect(body.model).toBe('vendor/model-a');
expect(body).not.toHaveProperty('models');
expect(body).not.toHaveProperty('usage');
});
it('treats a malformed URL as a custom gateway (model only)', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b'],
messages: MESSAGES,
apiUrl: 'not a url',
});
expect(body.model).toBe('vendor/model-a');
expect(body).not.toHaveProperty('models');
expect(body).not.toHaveProperty('usage');
});
it('carries the existing sampling parameters unchanged', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a', fallbackModels: [], messages: MESSAGES, apiUrl: DEFAULT_OPENROUTER_API_URL,
@@ -154,3 +203,16 @@ describe('buildOpenRouterRequestBody', () => {
expect(body.messages).toEqual(MESSAGES);
});
});
describe('isOpenRouterApiUrl', () => {
it('matches the real openrouter.ai hostname, case-insensitively', () => {
expect(isOpenRouterApiUrl(DEFAULT_OPENROUTER_API_URL)).toBe(true);
expect(isOpenRouterApiUrl('https://OpenRouter.AI/api/v1/chat/completions')).toBe(true);
});
it('rejects path-text, lookalike hosts, and malformed URLs', () => {
expect(isOpenRouterApiUrl('https://gateway.example.com/proxy/openrouter.ai/v1/chat/completions')).toBe(false);
expect(isOpenRouterApiUrl('https://openrouter.ai.evil.example/v1/chat/completions')).toBe(false);
expect(isOpenRouterApiUrl('not a url')).toBe(false);
});
});