fix(openrouter): map the model list onto the native models[] fallback array (#3971)

CLAUDE_MEM_OPENROUTER_MODEL has always accepted an array, and
normalizeOpenRouterModel has always comma-joined it into a single `model`
string. OpenRouter rejects that: there is no model id containing a comma, so
the joined form is never anything a user asked for. Meanwhile OpenRouter's
native `models` fallback array — the mechanism that actually expresses "try A,
fall back to B" — was never used. That is #3829 item 3.

The first configured entry becomes `model` and the rest become the fallback
array, in priority order. A comma- or whitespace-separated STRING is the same
mistake typed a different way, so it is split too; blanks and repeats are
dropped, since a repeat would spend a fallback slot re-trying the model that
just failed. A non-string scalar still resolves to the shipped default, as
before.

Per OpenRouter's documented shape, `models` REPLACES `model` rather than
accompanying it, so the body carries one or the other and never both. The
request body moves into an exported buildOpenRouterRequestBody so that shape is
assertable without a network round trip — getting it wrong would fail silently
at exactly the moment failover was supposed to help.

`models` is sent only to openrouter.ai. A custom gateway reached through
CLAUDE_MEM_OPENROUTER_BASE_URL speaks plain OpenAI, where an unknown body field
is a 400 — the same reason `usage: { include: true }` is already gated. Such a
gateway now gets the first model instead of a rejected comma-joined string.

A single configured model is untouched: same `model` field, same body, no
`models` key. That is every install that has not opted in.

This addresses only item 3 of #3829. The per-attempt timeout and max_tokens
knobs (items 1-2, and #3794 / #3808 / #3796) and the unbounded observer history
(item 4) are separate. Note that the default this still falls back to,
xiaomi/mimo-v2-flash:free, is the deprecated id #3662 is about.

Co-authored-by: nasif-naseef <naseef771@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-09-10 18:04:37 -07:00
committed by GitHub
parent cd5108c1db
commit e8aa53db61
3 changed files with 263 additions and 21 deletions
+26 -1
View File
@@ -77,7 +77,7 @@ All free models support:
|---------|--------|---------|-------------|
| `CLAUDE_MEM_PROVIDER` | `claude`, `gemini`, `openrouter` | `claude` | AI provider for observation extraction |
| `CLAUDE_MEM_OPENROUTER_API_KEY` | string | — | Your OpenRouter API key |
| `CLAUDE_MEM_OPENROUTER_MODEL` | string | `xiaomi/mimo-v2-flash:free` | Model identifier (see list above) |
| `CLAUDE_MEM_OPENROUTER_MODEL` | string or array | `xiaomi/mimo-v2-flash:free` | Model identifier, or a list to fall back through (see [Model fallbacks](#model-fallbacks)) |
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | string | — | Optional: URL for analytics attribution |
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | string | `claude-mem` | Optional: App name for analytics |
@@ -103,6 +103,31 @@ Edit `~/.claude-mem/settings.json`:
}
```
### Model fallbacks
`CLAUDE_MEM_OPENROUTER_MODEL` also accepts a **list** of model ids. The first is
the one normally used; the rest are handed to OpenRouter's native `models`
fallback array, which tries them in order when the one before it errors.
```json
{
"CLAUDE_MEM_PROVIDER": "openrouter",
"CLAUDE_MEM_OPENROUTER_API_KEY": "sk-or-v1-your-key-here",
"CLAUDE_MEM_OPENROUTER_MODEL": [
"vendor/fast-model:free",
"vendor/backup-model:free",
"vendor/paid-model"
]
}
```
A comma- or space-separated string works too, so
`"vendor/a, vendor/b"` means the same thing as the two-entry array.
Fallbacks are sent only to `openrouter.ai`. A custom
`CLAUDE_MEM_OPENROUTER_BASE_URL` gateway speaks plain OpenAI, which has no
`models` field, so it receives the **first** id and ignores the rest.
Alternatively, set the API key via environment variable:
```bash
+81 -20
View File
@@ -210,7 +210,14 @@ interface OpenRouterResponse {
export interface OpenRouterConfig {
apiKey: string;
/** First entry of the configured list; the one named in logs and sessions. */
model: string;
/**
* The rest of the configured list, in priority order, sent as OpenRouter's
* native `models` fallback array. Empty for the ordinary single-model
* configuration, which is every install that has not opted in.
*/
fallbackModels: string[];
apiUrl: string;
siteUrl?: string;
appName?: string;
@@ -220,12 +227,73 @@ function hasProcessEnvOverride(key: string): boolean {
return Object.prototype.hasOwnProperty.call(process.env, key);
}
function normalizeOpenRouterModel(rawModel: unknown): string {
return typeof rawModel === 'string' && rawModel.trim()
? rawModel
: Array.isArray(rawModel) && rawModel.length > 0
? rawModel.map(String).join(',')
: SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_OPENROUTER_MODEL;
/**
* Split CLAUDE_MEM_OPENROUTER_MODEL into a primary model and its fallbacks.
*
* The setting has always accepted an array, and the array has always been
* comma-joined into a single `model` string that OpenRouter rejects outright —
* #3829 item 3. No model id contains a comma, so the joined form is never
* anything a user wanted; a comma- or whitespace-separated STRING is the same
* mistake typed a different way, and is split here too.
*
* The first entry becomes `model`, which keeps every single-model install
* byte-identical, and the rest become OpenRouter's native `models` fallback
* array. Blanks and repeats are dropped: a repeat would spend a fallback slot
* re-trying the model that just failed.
*/
export function normalizeOpenRouterModel(rawModel: unknown): { model: string; fallbackModels: string[] } {
const parts = (Array.isArray(rawModel) ? rawModel : [rawModel])
// Strings only: a non-string scalar resolved to the default before this
// change, and no model id is a bare number.
.filter((entry): entry is string => typeof entry === 'string')
.flatMap(entry => entry.split(/[\s,]+/))
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
const unique = [...new Set(parts)];
if (unique.length === 0) {
return {
model: SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_OPENROUTER_MODEL,
fallbackModels: [],
};
}
return { model: unique[0], fallbackModels: unique.slice(1) };
}
/**
* Build the chat-completions request body.
*
* Exported so the body shape is testable without a network round trip, which
* matters here: in OpenRouter's documented fallback shape `models` REPLACES
* `model` rather than accompanying it, and that is not a thing to get wrong
* silently.
*
* `models` is only sent to openrouter.ai. A custom gateway reached through
* CLAUDE_MEM_OPENROUTER_BASE_URL speaks plain OpenAI, where an unknown body
* field is a 400 — the same reason `usage` is already gated. Such a gateway
* gets the first model, still strictly better than today's rejected
* comma-joined string.
*/
export function buildOpenRouterRequestBody(input: {
model: string;
fallbackModels: string[];
messages: OpenAIMessage[];
apiUrl: string;
}): Record<string, unknown> {
const isOpenRouter = input.apiUrl.includes('openrouter.ai');
const useFallbacks = isOpenRouter && input.fallbackModels.length > 0;
return {
...(useFallbacks
? { models: [input.model, ...input.fallbackModels] }
: { model: input.model }),
messages: input.messages,
temperature: 0.3, // Lower temperature for structured extraction
max_tokens: 4096,
// Ask openrouter.ai for usage accounting (token counts + cost).
// Only sent to openrouter.ai — strict custom gateways may reject
// unknown body fields.
...(isOpenRouter ? { usage: { include: true } } : {}),
};
}
/**
@@ -289,13 +357,13 @@ export function resolveOpenRouterConfig(
// operator supplied a model override as part of the new tuple.
rawModel = SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_OPENROUTER_MODEL;
}
const model = normalizeOpenRouterModel(rawModel);
const { model, fallbackModels } = normalizeOpenRouterModel(rawModel);
const apiUrl = resolveOpenRouterChatCompletionsUrl(baseUrl);
const siteUrl = settings.CLAUDE_MEM_OPENROUTER_SITE_URL || '';
const appName = settings.CLAUDE_MEM_OPENROUTER_APP_NAME || OPENROUTER_APP_TITLE;
return { apiKey, model, apiUrl, siteUrl, appName };
return { apiKey, model, fallbackModels, apiUrl, siteUrl, appName };
}
export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfig> {
@@ -349,7 +417,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
}
protected async query(history: ConversationMessage[], config: OpenRouterConfig, signal?: AbortSignal): Promise<ProviderQueryResult> {
return this.queryOpenRouterMultiTurn(history, config.apiKey, config.model, config.apiUrl, config.siteUrl, config.appName, signal);
return this.queryOpenRouterMultiTurn(history, config.apiKey, config.model, config.fallbackModels, config.apiUrl, config.siteUrl, config.appName, signal);
}
/** POST the chat-completions request. Extracted so the retry try block stays narrow. */
@@ -357,6 +425,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
apiUrl: string,
apiKey: string,
model: string,
fallbackModels: string[],
messages: OpenAIMessage[],
siteUrl: string | undefined,
appName: string | undefined,
@@ -371,16 +440,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
'Content-Type': 'application/json',
...(priorRequestId ? { 'x-claude-mem-prior-request-id': priorRequestId } : {}),
},
body: JSON.stringify({
model,
messages,
temperature: 0.3, // Lower temperature for structured extraction
max_tokens: 4096,
// Ask openrouter.ai for usage accounting (token counts + cost).
// Only sent to openrouter.ai — strict custom gateways may reject
// unknown body fields.
...(apiUrl.includes('openrouter.ai') ? { usage: { include: true } } : {}),
}),
body: JSON.stringify(buildOpenRouterRequestBody({ model, fallbackModels, messages, apiUrl })),
signal: attemptSignal,
});
}
@@ -389,6 +449,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
history: ConversationMessage[],
apiKey: string,
model: string,
fallbackModels: string[],
apiUrl: string,
siteUrl?: string,
appName?: string,
@@ -409,7 +470,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
const data = await withRetry<OpenRouterResponse>(async (attemptSignal) => {
let response: Response;
try {
response = await this.fetchChatCompletion(apiUrl, apiKey, model, messages, siteUrl, appName, priorRequestId, attemptSignal);
response = await this.fetchChatCompletion(apiUrl, apiKey, model, fallbackModels, messages, siteUrl, appName, priorRequestId, attemptSignal);
} catch (networkError: unknown) {
const err = networkError instanceof Error ? networkError : new Error(String(networkError));
throw classifyOpenRouterError({ cause: err });
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
buildOpenRouterRequestBody,
resolveOpenRouterConfig,
} from '../../src/services/worker/OpenRouterProvider.js';
import { DEFAULT_OPENROUTER_API_URL } from '../../src/shared/openrouter-base-url.js';
import { SettingsDefaultsManager } from '../../src/shared/SettingsDefaultsManager.js';
const ENV_KEYS = [
'CLAUDE_MEM_OPENROUTER_API_KEY',
'CLAUDE_MEM_OPENROUTER_BASE_URL',
'CLAUDE_MEM_OPENROUTER_MODEL',
'OPENROUTER_BASE_URL',
'CLAUDE_MEM_ENV_FILE',
'CMEM_PRO_ORIGIN',
] as const;
const MESSAGES = [{ role: 'user' as const, content: 'hi' }];
describe('CLAUDE_MEM_OPENROUTER_MODEL as a fallback list', () => {
let tempDir: string;
let settingsPath: string;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
tempDir = join(tmpdir(), `openrouter-fallback-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
settingsPath = join(tempDir, 'settings.json');
savedEnv = {};
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
process.env.CLAUDE_MEM_ENV_FILE = join(tempDir, '.env');
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
rmSync(tempDir, { recursive: true, force: true });
});
const write = (model: unknown): void => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_OPENROUTER_API_KEY: 'sk-or-personal',
CLAUDE_MEM_OPENROUTER_MODEL: model,
}));
};
it('keeps a single model id exactly as it is today, with no fallbacks', () => {
write('vendor/model-a');
const config = resolveOpenRouterConfig(settingsPath);
expect(config.model).toBe('vendor/model-a');
expect(config.fallbackModels).toEqual([]);
});
it('takes the first array entry as the model and the rest as fallbacks', () => {
write(['vendor/model-a', 'vendor/model-b', 'vendor/model-c']);
const config = resolveOpenRouterConfig(settingsPath);
expect(config.model).toBe('vendor/model-a');
expect(config.fallbackModels).toEqual(['vendor/model-b', 'vendor/model-c']);
});
it('never comma-joins an array into one unusable model id', () => {
write(['vendor/model-a', 'vendor/model-b']);
expect(resolveOpenRouterConfig(settingsPath).model).not.toContain(',');
});
it('splits a comma- or newline-separated string, which is equally unusable as one id', () => {
write('vendor/model-a, vendor/model-b');
const config = resolveOpenRouterConfig(settingsPath);
expect(config.model).toBe('vendor/model-a');
expect(config.fallbackModels).toEqual(['vendor/model-b']);
});
it('drops blanks and duplicates rather than spending a fallback slot on them', () => {
write(['vendor/model-a', '', ' ', 'vendor/model-a', 'vendor/model-b']);
const config = resolveOpenRouterConfig(settingsPath);
expect(config.model).toBe('vendor/model-a');
expect(config.fallbackModels).toEqual(['vendor/model-b']);
});
it('falls back to the shipped default for an empty or unusable setting', () => {
const shipped = SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_OPENROUTER_MODEL;
for (const raw of [[], '', ' ', ',,', 42]) {
write(raw);
const config = resolveOpenRouterConfig(settingsPath);
expect(config.model).toBe(shipped);
expect(config.fallbackModels).toEqual([]);
}
});
});
describe('buildOpenRouterRequestBody', () => {
it('sends a bare model field when there is no fallback list — the unchanged path', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: [],
messages: MESSAGES,
apiUrl: DEFAULT_OPENROUTER_API_URL,
});
expect(body.model).toBe('vendor/model-a');
expect(body).not.toHaveProperty('models');
});
it('sends models[] in priority order and NO model field when fallbacks exist', () => {
// OpenRouter's documented shape: the array replaces `model`, and entries
// are tried in order.
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b', 'vendor/model-c'],
messages: MESSAGES,
apiUrl: DEFAULT_OPENROUTER_API_URL,
});
expect(body.models).toEqual(['vendor/model-a', 'vendor/model-b', 'vendor/model-c']);
expect(body).not.toHaveProperty('model');
});
it('keeps a single model field for a non-openrouter.ai gateway, which may reject models[]', () => {
const body = buildOpenRouterRequestBody({
model: 'vendor/model-a',
fallbackModels: ['vendor/model-b'],
messages: MESSAGES,
apiUrl: 'https://gateway.example.com/v1/chat/completions',
});
expect(body.model).toBe('vendor/model-a');
expect(body).not.toHaveProperty('models');
});
it('keeps the usage-accounting flag gated on openrouter.ai exactly as before', () => {
const onOpenRouter = buildOpenRouterRequestBody({
model: 'vendor/model-a', fallbackModels: [], messages: MESSAGES, apiUrl: DEFAULT_OPENROUTER_API_URL,
});
const elsewhere = buildOpenRouterRequestBody({
model: 'vendor/model-a', fallbackModels: [], messages: MESSAGES, apiUrl: 'https://gateway.example.com/v1/chat/completions',
});
expect(onOpenRouter.usage).toEqual({ include: true });
expect(elsewhere).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,
});
expect(body.temperature).toBe(0.3);
expect(body.max_tokens).toBe(4096);
expect(body.messages).toEqual(MESSAGES);
});
});