mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 20:45:45 +08:00
Claude's model catalog changes quickly enough that a shared three-day cache can leave users selecting stale defaults or missing newly available model aliases. Route Claude model lookups through the provider every time so the UI and slash commands reflect the current provider result instead of an old disk snapshot. Keep the static fallback catalog aligned with the latest Claude defaults so the provider still has a sensible response when live discovery is unavailable.
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
createProviderModelsService,
|
|
PROVIDER_MODELS_CACHE_TTL_MS,
|
|
} from '@/modules/providers/services/provider-models.service.js';
|
|
import type {
|
|
ProviderChangeActiveModelInput,
|
|
LLMProvider,
|
|
ProviderCurrentActiveModel,
|
|
ProviderModelsDefinition,
|
|
ProviderSessionActiveModelChange,
|
|
} from '@/shared/types.js';
|
|
import { writeProviderSessionActiveModelChange } from '@/shared/utils.js';
|
|
|
|
const createModels = (value: string): ProviderModelsDefinition => ({
|
|
OPTIONS: [{ value, label: value }],
|
|
DEFAULT: value,
|
|
});
|
|
|
|
const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
|
|
model,
|
|
});
|
|
|
|
const createSessionActiveModelChange = (
|
|
provider: LLMProvider,
|
|
input: ProviderChangeActiveModelInput,
|
|
): ProviderSessionActiveModelChange => ({
|
|
provider,
|
|
sessionId: input.sessionId,
|
|
supported: true,
|
|
changed: true,
|
|
model: input.model,
|
|
});
|
|
|
|
const createEphemeralCachePath = (): string => path.join(
|
|
os.tmpdir(),
|
|
`provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
|
);
|
|
|
|
test('provider models service delegates to the resolved provider model adapter', async () => {
|
|
const calls: LLMProvider[] = [];
|
|
const service = createProviderModelsService({
|
|
cachePath: createEphemeralCachePath(),
|
|
resolveProvider: (provider) => {
|
|
calls.push(provider);
|
|
return {
|
|
models: {
|
|
getSupportedModels: async () => createModels(`${provider}-models`),
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const models = await service.getProviderModels('codex', { bypassCache: true });
|
|
|
|
assert.deepEqual(calls, ['codex']);
|
|
assert.equal(models.models.DEFAULT, 'codex-models');
|
|
assert.equal(models.cache.source, 'fresh');
|
|
});
|
|
|
|
test('provider models service returns each provider adapter result without rewriting it', async () => {
|
|
const expectedModels: ProviderModelsDefinition = {
|
|
OPTIONS: [
|
|
{ value: 'cursor-a', label: 'Cursor A' },
|
|
{ value: 'cursor-b', label: 'Cursor B' },
|
|
],
|
|
DEFAULT: 'cursor-b',
|
|
};
|
|
|
|
const service = createProviderModelsService({
|
|
cachePath: createEphemeralCachePath(),
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => expectedModels,
|
|
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const models = await service.getProviderModels('cursor', { bypassCache: true });
|
|
|
|
assert.deepEqual(models.models, expectedModels);
|
|
});
|
|
|
|
test('provider models are cached for the three-day ttl', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
|
|
let currentTime = 1_000;
|
|
let loadCount = 0;
|
|
|
|
try {
|
|
const service = createProviderModelsService({
|
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
|
now: () => currentTime,
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
loadCount += 1;
|
|
return createModels(`${provider}-${loadCount}`);
|
|
},
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const first = await service.getProviderModels('codex');
|
|
const cached = await service.getProviderModels('codex');
|
|
assert.equal(loadCount, 1);
|
|
assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
|
|
assert.equal(cached.cache.source, 'memory');
|
|
|
|
currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
|
|
await service.getProviderModels('codex');
|
|
assert.equal(loadCount, 1);
|
|
|
|
currentTime += 2;
|
|
const refreshed = await service.getProviderModels('codex');
|
|
assert.equal(loadCount, 2);
|
|
assert.equal(refreshed.models.DEFAULT, 'codex-2');
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('claude provider models are always loaded directly from the provider', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
|
|
let loadCount = 0;
|
|
|
|
try {
|
|
const service = createProviderModelsService({
|
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
loadCount += 1;
|
|
return createModels(`${provider}-${loadCount}`);
|
|
},
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const first = await service.getProviderModels('claude');
|
|
const second = await service.getProviderModels('claude');
|
|
|
|
assert.equal(loadCount, 2);
|
|
assert.equal(first.models.DEFAULT, 'claude-1');
|
|
assert.equal(second.models.DEFAULT, 'claude-2');
|
|
assert.equal(second.cache.source, 'fresh');
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('provider model cache is persisted across service instances', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
|
const cachePath = path.join(tempRoot, 'models-cache.json');
|
|
|
|
try {
|
|
const writer = createProviderModelsService({
|
|
cachePath,
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => createModels('gemini-cached'),
|
|
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
|
},
|
|
}),
|
|
});
|
|
await writer.getProviderModels('gemini');
|
|
|
|
const reader = createProviderModelsService({
|
|
cachePath,
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
throw new Error('loader should not be called for persisted cache hits');
|
|
},
|
|
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
|
|
},
|
|
}),
|
|
});
|
|
const models = await reader.getProviderModels('gemini');
|
|
assert.equal(models.models.DEFAULT, 'gemini-cached');
|
|
assert.equal(models.cache.source, 'disk');
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('concurrent provider model requests share one load operation', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
|
|
let loadCount = 0;
|
|
|
|
try {
|
|
const service = createProviderModelsService({
|
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
loadCount += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
return createModels('claude-cached');
|
|
},
|
|
getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange('claude', input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
service.getProviderModels('claude'),
|
|
service.getProviderModels('claude'),
|
|
]);
|
|
|
|
assert.equal(loadCount, 1);
|
|
assert.equal(first.models.DEFAULT, 'claude-cached');
|
|
assert.equal(second.models.DEFAULT, 'claude-cached');
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
|
|
let currentTime = 1_000;
|
|
let loadCount = 0;
|
|
|
|
try {
|
|
const service = createProviderModelsService({
|
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
|
now: () => currentTime,
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
loadCount += 1;
|
|
return createModels(`${provider}-${loadCount}`);
|
|
},
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const first = await service.getProviderModels('claude');
|
|
currentTime += 50;
|
|
const refreshed = await service.getProviderModels('claude', { bypassCache: true });
|
|
|
|
assert.equal(first.models.DEFAULT, 'claude-1');
|
|
assert.equal(refreshed.models.DEFAULT, 'claude-2');
|
|
assert.equal(refreshed.cache.source, 'fresh');
|
|
assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
|
|
assert.equal(loadCount, 2);
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('provider models service delegates current active model lookups to the provider adapter', async () => {
|
|
const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
|
|
const service = createProviderModelsService({
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => createModels(`${provider}-models`),
|
|
getCurrentActiveModel: async (sessionId) => {
|
|
calls.push({ provider, sessionId });
|
|
return createCurrentActiveModel(`${provider}-${sessionId}`);
|
|
},
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
const activeModel = await service.getCurrentActiveModel('opencode', 'session-123');
|
|
|
|
assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
|
|
assert.equal(activeModel.model, 'opencode-session-123');
|
|
});
|
|
|
|
test('provider models service delegates active model change requests to the provider adapter', async () => {
|
|
const calls: Array<{ provider: LLMProvider; input: ProviderChangeActiveModelInput }> = [];
|
|
const service = createProviderModelsService({
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => createModels(`${provider}-models`),
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
|
changeActiveModel: async (input) => {
|
|
calls.push({ provider, input });
|
|
return createSessionActiveModelChange(provider, input);
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const changedModel = await service.changeActiveModel('claude', {
|
|
sessionId: 'session-123',
|
|
model: 'opus',
|
|
});
|
|
|
|
assert.deepEqual(calls, [{
|
|
provider: 'claude',
|
|
input: {
|
|
sessionId: 'session-123',
|
|
model: 'opus',
|
|
},
|
|
}]);
|
|
assert.equal(changedModel.changed, true);
|
|
assert.equal(changedModel.model, 'opus');
|
|
});
|
|
|
|
test('resolveResumeModel prefers a stored changed model over the requested one', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-change-'));
|
|
const activeModelChangesPath = path.join(tempRoot, 'session-model-changes.json');
|
|
|
|
try {
|
|
const service = createProviderModelsService({
|
|
activeModelChangesPath,
|
|
resolveProvider: (provider) => ({
|
|
models: {
|
|
getSupportedModels: async () => createModels(`${provider}-models`),
|
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
|
},
|
|
}),
|
|
});
|
|
|
|
await writeProviderSessionActiveModelChange('cursor', {
|
|
sessionId: 'session-456',
|
|
model: 'composer-2',
|
|
}, {
|
|
filePath: activeModelChangesPath,
|
|
});
|
|
|
|
const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
|
|
assert.equal(model, 'composer-2');
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|