Files
claudecodeui/server/modules/providers/tests/provider-models.service.test.ts
Haileyesus 9aa927002e feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.
2026-05-18 16:57:29 +03:00

319 lines
11 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('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 });
}
});