Files
claudecodeui/server/modules/providers/tests/provider-models.service.test.ts

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('cursor-cached'),
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
},
}),
});
await writer.getProviderModels('cursor');
const reader = createProviderModelsService({
cachePath,
resolveProvider: () => ({
models: {
getSupportedModels: async () => {
throw new Error('loader should not be called for persisted cache hits');
},
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
},
}),
});
const models = await reader.getProviderModels('cursor');
assert.equal(models.models.DEFAULT, 'cursor-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 });
}
});