diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 1b939256..0e5f7477 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -1,6 +1,6 @@ -import Database from 'better-sqlite3'; import { spawn } from 'node:child_process'; +import Database from 'better-sqlite3'; import crossSpawn from 'cross-spawn'; import type { IProviderModels } from '@/shared/interfaces.js'; @@ -21,14 +21,46 @@ import { 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' }, + { + value: 'anthropic/claude-sonnet-4-5', + label: 'Claude Sonnet 4.5', + description: 'anthropic - anthropic/claude-sonnet-4-5', + }, + { + value: 'anthropic/claude-opus-4-1', + label: 'Claude Opus 4.1', + description: 'anthropic - anthropic/claude-opus-4-1', + }, + { + value: 'anthropic/claude-haiku-4-5', + label: 'Claude Haiku 4.5', + description: 'anthropic - anthropic/claude-haiku-4-5', + }, + { + value: 'openai/gpt-5.1', + label: 'GPT-5.1', + description: 'openai - openai/gpt-5.1', + }, + { + value: 'openai/gpt-5.1-codex', + label: 'GPT-5.1 Codex', + description: 'openai - openai/gpt-5.1-codex', + }, + { + value: 'openai/gpt-5.4-mini', + label: 'GPT-5.4 Mini', + description: 'openai - openai/gpt-5.4-mini', + }, + { + value: 'google/gemini-2.5-pro', + label: 'Gemini 2.5 Pro', + description: 'google - google/gemini-2.5-pro', + }, + { + value: 'google/gemini-2.5-flash', + label: 'Gemini 2.5 Flash', + description: 'google - google/gemini-2.5-flash', + }, ], DEFAULT: 'anthropic/claude-sonnet-4-5', }; @@ -36,8 +68,13 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { 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 DATE_TOKEN = /^\d{8}$/; +const SIMPLE_NUMBER_TOKEN = /^\d$/; +const VERSION_TOKEN = /^[a-z]\d+$/i; +const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/; +const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/; -const parseOpenCodeModelsStdout = (stdout: string): string[] => { +export const parseOpenCodeModelsStdout = (stdout: string): string[] => { const ids: string[] = []; for (const rawLine of stdout.split(/\r?\n/)) { @@ -54,20 +91,90 @@ const parseOpenCodeModelsStdout = (stdout: string): string[] => { return [...new Set(ids)]; }; +const formatDateToken = (token: string): string => ( + `${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}` +); + +const formatModelToken = (token: string, nextToken?: string): string => { + const lower = token.toLowerCase(); + + if (VERSION_TOKEN.test(token)) { + return token.toUpperCase(); + } + + if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) { + return token.toUpperCase(); + } + + return lower.charAt(0).toUpperCase() + lower.slice(1); +}; + +const formatOpenCodeModelSlug = (slug: string): string => { + const labelParts: string[] = []; + const dateParts: string[] = []; + const tokens = slug.split('-').filter(Boolean); + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const nextToken = tokens[index + 1]; + + if (DATE_TOKEN.test(token)) { + dateParts.push(formatDateToken(token)); + continue; + } + + if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) { + labelParts.push(`${token}.${nextToken}`); + index += 1; + continue; + } + + labelParts.push(formatModelToken(token, nextToken)); + } + + const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-'); + if (dateParts.length === 0) { + return label; + } + + return `${label} (${dateParts.join(', ')})`; +}; + +const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => { + const separatorIndex = id.indexOf('/'); + if (separatorIndex < 0) { + return { + upstreamProvider: '', + slug: id, + }; + } + + return { + upstreamProvider: id.slice(0, separatorIndex), + slug: id.slice(separatorIndex + 1), + }; +}; + 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 { slug } = readOpenCodeModelParts(id); + return formatOpenCodeModelSlug(slug); }; -const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { +const descriptionForOpenCodeModelId = (id: string): string => { + const { upstreamProvider } = readOpenCodeModelParts(id); + return upstreamProvider ? `${upstreamProvider} - ${id}` : id; +}; + +export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { const options: ProviderModelOption[] = ids.map((value) => ({ value, label: labelForOpenCodeModelId(value), + description: descriptionForOpenCodeModelId(value), })); const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value diff --git a/server/modules/providers/tests/opencode-models.test.ts b/server/modules/providers/tests/opencode-models.test.ts new file mode 100644 index 00000000..c28ac0ef --- /dev/null +++ b/server/modules/providers/tests/opencode-models.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildOpenCodeDefinitionFromIds, + parseOpenCodeModelsStdout, +} from '@/modules/providers/list/opencode/opencode-models.provider.js'; + +test('OpenCode models provider parses plain CLI output and removes duplicates', () => { + const ids = parseOpenCodeModelsStdout(` +opencode/big-pickle +not a model +anthropic/claude-opus-4-7-fast +anthropic/claude-opus-4-7-fast +openai/gpt-5.5-pro +`); + + assert.deepEqual(ids, [ + 'opencode/big-pickle', + 'anthropic/claude-opus-4-7-fast', + 'openai/gpt-5.5-pro', + ]); +}); + +test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => { + const definition = buildOpenCodeDefinitionFromIds([ + 'opencode/deepseek-v4-flash-free', + 'opencode/nemotron-3-super-free', + 'anthropic/claude-3-5-sonnet-20241022', + 'anthropic/claude-opus-4-7-fast', + 'openai/gpt-5.4-mini-fast', + 'openai/gpt-5.5-pro', + 'newprovider/alpha-v12-special-20261231', + ]); + + assert.deepEqual(definition.OPTIONS, [ + { + value: 'opencode/deepseek-v4-flash-free', + label: 'Deepseek V4 Flash Free', + description: 'opencode - opencode/deepseek-v4-flash-free', + }, + { + value: 'opencode/nemotron-3-super-free', + label: 'Nemotron 3 Super Free', + description: 'opencode - opencode/nemotron-3-super-free', + }, + { + value: 'anthropic/claude-3-5-sonnet-20241022', + label: 'Claude 3.5 Sonnet (2024-10-22)', + description: 'anthropic - anthropic/claude-3-5-sonnet-20241022', + }, + { + value: 'anthropic/claude-opus-4-7-fast', + label: 'Claude Opus 4.7 Fast', + description: 'anthropic - anthropic/claude-opus-4-7-fast', + }, + { + value: 'openai/gpt-5.4-mini-fast', + label: 'GPT-5.4 Mini Fast', + description: 'openai - openai/gpt-5.4-mini-fast', + }, + { + value: 'openai/gpt-5.5-pro', + label: 'GPT-5.5 Pro', + description: 'openai - openai/gpt-5.5-pro', + }, + { + value: 'newprovider/alpha-v12-special-20261231', + label: 'Alpha V12 Special (2026-12-31)', + description: 'newprovider - newprovider/alpha-v12-special-20261231', + }, + ]); +});