feat: load models through provider adapters

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.
This commit is contained in:
Haileyesus
2026-05-18 12:40:24 +03:00
parent ffaef395e4
commit 556cbd1a03
28 changed files with 1125 additions and 483 deletions

View File

@@ -0,0 +1,139 @@
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1' },
{ value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ value: 'openai/gpt-5.1', label: 'GPT-5.1' },
{ value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini' },
{ value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
],
DEFAULT: 'anthropic/claude-sonnet-4-5',
};
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const parseOpenCodeModelsStdout = (stdout: string): string[] => {
const ids: string[] = [];
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('{') || line.startsWith('[')) {
continue;
}
if (MODEL_ID_LINE.test(line)) {
ids.push(line);
}
}
return [...new Set(ids)];
};
const labelForOpenCodeModelId = (id: string): string => {
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
if (fallbackLabel) {
return fallbackLabel;
}
const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
return tail.replace(/-/g, ' ');
};
const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
}));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
?? options[0]?.value
?? OPENCODE_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
const openCodeProcess = spawnFunction('opencode', ['models'], {
cwd: process.cwd(),
env: { ...process.env },
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
openCodeProcess.kill('SIGTERM');
if (!settled) {
settled = true;
reject(new Error('opencode models timed out'));
}
}, OPEN_CODE_MODELS_TIMEOUT_MS);
const finish = (error: Error | null, output: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (error) {
reject(error);
return;
}
resolve(output);
};
openCodeProcess.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
openCodeProcess.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
openCodeProcess.on('error', (error) => {
finish(error instanceof Error ? error : new Error(String(error)), '');
});
openCodeProcess.on('close', (code) => {
if (code !== 0) {
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
return;
}
finish(null, stdout);
});
});
export class OpenCodeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
const stdout = await runOpenCodeModelsCommand();
const ids = parseOpenCodeModelsStdout(stdout);
if (ids.length === 0) {
return OPENCODE_FALLBACK_MODELS;
}
return buildOpenCodeDefinitionFromIds(ids);
} catch {
return OPENCODE_FALLBACK_MODELS;
}
}
}

View File

@@ -1,4 +1,5 @@
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js';
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
@@ -6,12 +7,14 @@ import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/openco
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class OpenCodeProvider extends AbstractProvider {
readonly models: IProviderModels = new OpenCodeProviderModels();
readonly mcp = new OpenCodeMcpProvider();
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();