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