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, ProviderCurrentActiveModel, ProviderModelsDefinition, } from '@/shared/types.js'; const createModels = (value: string): ProviderModelsDefinition => ({ OPTIONS: [{ value, label: value }], DEFAULT: value, }); const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({ 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`), }, }; }, }); 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'), }, }), }); 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`), }, }), }); 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'), getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'), }, }), }); 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'), }, }), }); 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'), }, }), }); 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}`), }, }), }); 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}`); }, }, }), }); const activeModel = await service.getCurrentActiveModel('opencode', 'session-123'); assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]); assert.equal(activeModel.model, 'opencode-session-123'); });