fix: format opencode model catalog labels

OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.
This commit is contained in:
Haileyesus
2026-05-22 17:02:03 +03:00
parent 6ca0d38fa4
commit fe3a8580dc
2 changed files with 193 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
import Database from 'better-sqlite3';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import Database from 'better-sqlite3';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js'; import type { IProviderModels } from '@/shared/interfaces.js';
@@ -21,14 +21,46 @@ import {
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ 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-sonnet-4-5',
{ value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' }, label: 'Claude Sonnet 4.5',
{ value: 'openai/gpt-5.1', label: 'GPT-5.1' }, description: 'anthropic - anthropic/claude-sonnet-4-5',
{ 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: 'anthropic/claude-opus-4-1',
{ value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, 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', 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 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 MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; 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[] = []; const ids: string[] = [];
for (const rawLine of stdout.split(/\r?\n/)) { for (const rawLine of stdout.split(/\r?\n/)) {
@@ -54,20 +91,90 @@ const parseOpenCodeModelsStdout = (stdout: string): string[] => {
return [...new Set(ids)]; 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 labelForOpenCodeModelId = (id: string): string => {
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label; const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
if (fallbackLabel) { if (fallbackLabel) {
return fallbackLabel; return fallbackLabel;
} }
const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id; const { slug } = readOpenCodeModelParts(id);
return tail.replace(/-/g, ' '); 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) => ({ const options: ProviderModelOption[] = ids.map((value) => ({
value, value,
label: labelForOpenCodeModelId(value), label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
})); }));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value

View File

@@ -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',
},
]);
});