Files
thedotmack__claude-mem/tests/worker/provider-dispatch.test.ts
2026-08-26 17:27:20 -07:00

210 lines
7.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
CMEM_FALLBACK_RETRY_MS,
getSelectedProvider,
recordCmemFallbackIfEligible,
shouldUseCmemFallback,
} from '../../src/services/worker/provider-dispatch.js';
import { classifyOpenRouterError } from '../../src/services/worker/OpenRouterProvider.js';
const CMEM_GATEWAY_BASE = 'https://cmem.ai/api/inference/v1';
/**
* The dispatch predicates read settings via SettingsDefaultsManager, which
* applies process.env overrides LAST — so pinning env vars (empty string
* included) fully controls the outcome regardless of the temp data dir's
* settings file. Preload (tests/preload.ts) already pins CLAUDE_MEM_DATA_DIR
* to a per-run temp dir, so no real ~/.claude-mem I/O happens here.
*/
const ENV_KEYS = [
'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_OPENROUTER_API_KEY',
'CLAUDE_MEM_OPENROUTER_BASE_URL',
'CLAUDE_MEM_PRO_FALLBACK_AT',
'CLAUDE_MEM_GEMINI_API_KEY',
'CMEM_PRO_ORIGIN',
] as const;
describe('provider-dispatch', () => {
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
savedEnv = {};
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
function pinOpenRouterEnv(overrides: Record<string, string> = {}): void {
process.env.CLAUDE_MEM_PROVIDER = 'openrouter';
process.env.CLAUDE_MEM_OPENROUTER_API_KEY = 'sk-or-test-key';
process.env.CLAUDE_MEM_OPENROUTER_BASE_URL = CMEM_GATEWAY_BASE;
process.env.CLAUDE_MEM_PRO_FALLBACK_AT = '';
for (const [key, value] of Object.entries(overrides)) {
process.env[key] = value;
}
}
describe('getSelectedProvider', () => {
it('returns openrouter when selected, keyed, and no fallback is recorded', () => {
pinOpenRouterEnv();
expect(getSelectedProvider()).toBe('openrouter');
});
it('returns claude when the fallback marker is set on a cmem-gateway config', () => {
pinOpenRouterEnv({ CLAUDE_MEM_PRO_FALLBACK_AT: new Date().toISOString() });
expect(getSelectedProvider()).toBe('claude');
});
it('allows a gateway recovery probe after the fallback cooldown', () => {
pinOpenRouterEnv({
CLAUDE_MEM_PRO_FALLBACK_AT: new Date(Date.now() - CMEM_FALLBACK_RETRY_MS - 1).toISOString(),
});
expect(getSelectedProvider()).toBe('openrouter');
});
it('ignores the fallback marker entirely for a user-owned openrouter.ai key', () => {
pinOpenRouterEnv({
CLAUDE_MEM_OPENROUTER_BASE_URL: '',
CLAUDE_MEM_PRO_FALLBACK_AT: '2026-08-26T12:00:00.000Z',
});
expect(getSelectedProvider()).toBe('openrouter');
pinOpenRouterEnv({
CLAUDE_MEM_OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
CLAUDE_MEM_PRO_FALLBACK_AT: '2026-08-26T12:00:00.000Z',
});
expect(getSelectedProvider()).toBe('openrouter');
});
it('ignores the fallback marker for deceptive cmem.ai hostname prefixes', () => {
pinOpenRouterEnv({
CLAUDE_MEM_OPENROUTER_BASE_URL: 'https://cmem.ai.evil.example/api/inference/v1',
CLAUDE_MEM_PRO_FALLBACK_AT: '2026-08-26T12:00:00.000Z',
});
expect(getSelectedProvider()).toBe('openrouter');
});
it('falls through to claude when openrouter is selected but has no key', () => {
pinOpenRouterEnv({ CLAUDE_MEM_OPENROUTER_API_KEY: '' });
expect(getSelectedProvider()).toBe('claude');
});
it('returns claude for the default provider selection', () => {
process.env.CLAUDE_MEM_PROVIDER = 'claude';
process.env.CLAUDE_MEM_GEMINI_API_KEY = '';
process.env.CLAUDE_MEM_OPENROUTER_API_KEY = '';
expect(getSelectedProvider()).toBe('claude');
});
});
describe('shouldUseCmemFallback', () => {
const now = Date.parse('2026-08-26T12:30:00.000Z');
it('uses Claude during the cooldown and probes once it expires', () => {
expect(shouldUseCmemFallback('2026-08-26T12:29:00.000Z', now)).toBe(true);
expect(shouldUseCmemFallback(
new Date(now - CMEM_FALLBACK_RETRY_MS).toISOString(),
now,
)).toBe(false);
});
it('keeps malformed non-empty markers safely fallen back', () => {
expect(shouldUseCmemFallback('not-an-iso-date', now)).toBe(true);
expect(shouldUseCmemFallback('', now)).toBe(false);
});
});
describe('recordCmemFallbackIfEligible', () => {
let tempDir: string;
let settingsPath: string;
beforeEach(() => {
tempDir = join(tmpdir(), `provider-dispatch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
settingsPath = join(tempDir, 'settings.json');
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function gatewayError(status: number, code: string): ReturnType<typeof classifyOpenRouterError> {
return classifyOpenRouterError({
status,
bodyText: JSON.stringify({ error: { code, message: `gateway said ${code}` } }),
cause: new Error(`upstream ${status}`),
});
}
it('records the fallback for a 402 allowance_exhausted from the cmem gateway', () => {
pinOpenRouterEnv();
const error = gatewayError(402, 'allowance_exhausted');
expect(error.kind).toBe('quota_exhausted');
expect(recordCmemFallbackIfEligible(error, settingsPath)).toBe(true);
const persisted = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(persisted.CLAUDE_MEM_PRO_FALLBACK_AT).not.toBe('');
expect(Number.isNaN(Date.parse(persisted.CLAUDE_MEM_PRO_FALLBACK_AT))).toBe(false);
});
it('records the fallback for a key_invalid gateway rejection', () => {
pinOpenRouterEnv();
const error = gatewayError(401, 'key_invalid');
expect(error.kind).toBe('auth_invalid');
expect(error.code).toBe('key_invalid');
expect(recordCmemFallbackIfEligible(error, settingsPath)).toBe(true);
});
it('records the fallback for a legacy (no-envelope) 402 on the gateway config', () => {
pinOpenRouterEnv();
const error = classifyOpenRouterError({
status: 402,
bodyText: 'Payment required',
cause: new Error('upstream 402'),
});
expect(error.kind).toBe('quota_exhausted');
expect(recordCmemFallbackIfEligible(error, settingsPath)).toBe(true);
});
it('never triggers for a user-owned openrouter.ai key running dry', () => {
pinOpenRouterEnv({ CLAUDE_MEM_OPENROUTER_BASE_URL: '' });
expect(recordCmemFallbackIfEligible(gatewayError(402, 'allowance_exhausted'), settingsPath)).toBe(false);
pinOpenRouterEnv({ CLAUDE_MEM_OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1' });
expect(recordCmemFallbackIfEligible(gatewayError(402, 'allowance_exhausted'), settingsPath)).toBe(false);
});
it('never triggers for deceptive cmem.ai hostname prefixes', () => {
pinOpenRouterEnv({
CLAUDE_MEM_OPENROUTER_BASE_URL: 'https://cmem.ai.evil.example/api/inference/v1',
});
expect(recordCmemFallbackIfEligible(gatewayError(402, 'allowance_exhausted'), settingsPath)).toBe(false);
const persisted = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(persisted.CLAUDE_MEM_PRO_FALLBACK_AT).toBe('');
});
it('ignores non-terminal gateway errors (rate limits, transient, inactive subscription)', () => {
pinOpenRouterEnv();
expect(recordCmemFallbackIfEligible(gatewayError(429, 'rate_limited'), settingsPath)).toBe(false);
expect(recordCmemFallbackIfEligible(gatewayError(503, 'upstream_unavailable'), settingsPath)).toBe(false);
expect(recordCmemFallbackIfEligible(gatewayError(403, 'subscription_inactive'), settingsPath)).toBe(false);
});
});
});