mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 14:55:34 +08:00
Provider model selection had outgrown a single hardcoded service. The old service mixed shared caching with provider catalogs and CLI lookup details. That made stale model lists more likely as providers changed on separate schedules. Move model discovery behind each provider so lookup lives next to the integration. The shared service now focuses on provider resolution, caching, persistence, and dedupe. Return cache metadata and add bypassCache because model availability changes outside the app. The UI and /models command can show freshness and let users force a provider refresh. Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
194 lines
5.9 KiB
TypeScript
194 lines
5.9 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 { LLMProvider, ProviderModelsDefinition } from '@/shared/types.js';
|
|
|
|
const createModels = (value: string): ProviderModelsDefinition => ({
|
|
OPTIONS: [{ value, label: value }],
|
|
DEFAULT: value,
|
|
});
|
|
|
|
test('provider models service delegates to the resolved provider model adapter', async () => {
|
|
const calls: LLMProvider[] = [];
|
|
const service = createProviderModelsService({
|
|
resolveProvider: (provider) => {
|
|
calls.push(provider);
|
|
return {
|
|
models: {
|
|
getSupportedModels: async () => createModels(`${provider}-models`),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const models = await service.getProviderModels('codex');
|
|
|
|
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({
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => expectedModels,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const models = await service.getProviderModels('cursor');
|
|
|
|
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}`);
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
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('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'),
|
|
},
|
|
}),
|
|
});
|
|
await writer.getProviderModels('gemini');
|
|
|
|
const reader = createProviderModelsService({
|
|
cachePath,
|
|
resolveProvider: () => ({
|
|
models: {
|
|
getSupportedModels: async () => {
|
|
throw new Error('loader should not be called for persisted cache hits');
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
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');
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
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}`);
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|