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 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