mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user