fix: better models.dev support

This commit is contained in:
2026-03-01 17:03:50 +01:00
parent e2c46e94aa
commit 63674266f5
9 changed files with 2140 additions and 371 deletions

View File

@@ -1,41 +1,65 @@
/**
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
*
* Provides model output token limits, pricing info, and capabilities
* for all models available through the OpenCode Zen gateway.
* The full catalog is stored in three normalised SQLite tables:
* model_catalog_providers — one row per provider (opencode, mistral, …)
* model_catalog — one row per (provider, modelId) pair
* model_catalog_modalities — junction table with (provider, modelId, direction, modality) tags
*
* Data is persisted in SQLite (model_catalog + model_catalog_meta tables)
* and refreshed on user action via conditional GET (ETag).
* Data is refreshed on user action via conditional GET (ETag).
* Works fully offline after first successful fetch.
*/
import https from 'https';
import http from 'http';
import { URL } from 'url';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { modelCatalog, modelCatalogMeta } from '../database/schema';
import { modelCatalog, modelCatalogMeta, modelCatalogProviders, modelCatalogModalities } from '../database/schema';
import type { ModelCatalogEntry } from '../database/schema';
const MODELS_DEV_URL = 'https://models.dev/api.json';
const PROVIDER_KEY = 'opencode';
// Default max output tokens when no catalog data is available
export const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
/** Provider-level metadata from models.dev. */
export interface ProviderInfo {
id: string;
name: string;
env: string[];
npm: string | null;
api: string | null;
doc: string | null;
}
/** Flattened model info returned by query methods. */
export interface ModelCatalogInfo {
provider: string;
id: string;
name: string;
family: string | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
attachment: boolean;
reasoning: boolean;
toolCall: boolean;
structuredOutput: boolean;
temperature: boolean;
knowledge: string | null;
releaseDate: string | null;
lastUpdatedDate: string | null;
openWeights: boolean;
inputPrice: number | null;
outputPrice: number | null;
cacheReadPrice: number | null;
supportsAttachments: boolean | null;
supportsReasoning: boolean | null;
supportsToolCall: boolean | null;
cacheWritePrice: number | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
interleaved: string | null;
status: string | null;
providerNpm: string | null;
inputModalities: string[];
outputModalities: string[];
}
export interface RefreshResult {
@@ -58,30 +82,87 @@ export class ModelCatalogEngine {
async getAll(): Promise<ModelCatalogInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog);
return rows.map(toInfo);
const modalities = await db.select().from(modelCatalogModalities);
return rows.map(r => toInfo(r, modalities));
}
/**
* Get a single model's catalog entry by ID.
* Get all models for a specific provider.
*/
async getModel(modelId: string): Promise<ModelCatalogInfo | null> {
async getByProvider(provider: string): Promise<ModelCatalogInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId));
return rows.length > 0 ? toInfo(rows[0]) : null;
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.provider, provider));
const modalities = await db.select().from(modelCatalogModalities).where(eq(modelCatalogModalities.provider, provider));
return rows.map(r => toInfo(r, modalities));
}
/**
* Get a single model by provider and model ID.
*/
async getModel(modelId: string, provider?: string): Promise<ModelCatalogInfo | null> {
const db = getDatabase().getLocal();
let rows: ModelCatalogEntry[];
if (provider) {
rows = await db.select().from(modelCatalog).where(
and(eq(modelCatalog.provider, provider), eq(modelCatalog.modelId, modelId)),
);
} else {
// Search across all providers, return first match
rows = await db.select().from(modelCatalog).where(eq(modelCatalog.modelId, modelId));
}
if (rows.length === 0) return null;
const row = rows[0];
const modalities = await db.select().from(modelCatalogModalities).where(
and(eq(modelCatalogModalities.provider, row.provider), eq(modelCatalogModalities.modelId, row.modelId)),
);
return toInfo(row, modalities);
}
/**
* Get all providers from the catalog.
*/
async getProviders(): Promise<ProviderInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalogProviders);
return rows.map(r => ({
id: r.id,
name: r.name,
env: r.env ? JSON.parse(r.env) as string[] : [],
npm: r.npm,
api: r.api,
doc: r.doc,
}));
}
/**
* Get the max output tokens for a model (used by OpenCodeManager for max_tokens).
* Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog.
*/
async getMaxOutputTokens(modelId: string): Promise<number> {
const model = await this.getModel(modelId);
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
const model = await this.getModel(modelId, provider);
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* Get the context window size for a model.
* Returns null if the model is not in the catalog.
*/
async getContextWindow(modelId: string, provider?: string): Promise<number | null> {
const model = await this.getModel(modelId, provider);
return model?.contextWindow ?? null;
}
/**
* Check whether a model supports a specific input modality (e.g. 'image').
*/
async hasInputModality(modelId: string, modality: string, provider?: string): Promise<boolean> {
const model = await this.getModel(modelId, provider);
return model?.inputModalities.includes(modality) ?? false;
}
/**
* Refresh the model catalog from models.dev using conditional GET (ETag).
* Returns the number of models updated, or notModified if the data hasn't changed.
* Stores ALL providers and ALL models from the API.
*/
async refresh(): Promise<RefreshResult> {
try {
@@ -109,9 +190,16 @@ export class ModelCatalogEngine {
// Parse response
const data = JSON.parse(response.body);
const models = data?.[PROVIDER_KEY]?.models;
if (!models || typeof models !== 'object') {
return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' };
if (!data || typeof data !== 'object') {
return { success: false, modelsUpdated: 0, error: 'Invalid response: not an object' };
}
// Count providers with models
const providerEntries = Object.entries(data).filter(
([, v]) => v && typeof v === 'object' && 'models' in (v as Record<string, unknown>),
);
if (providerEntries.length === 0) {
return { success: false, modelsUpdated: 0, error: 'Invalid response: no providers found' };
}
// Store new ETag
@@ -121,10 +209,18 @@ export class ModelCatalogEngine {
}
await this.setMeta('lastFetchedAt', new Date().toISOString());
// Upsert all models
const count = await this.upsertModels(models);
// Upsert all providers and their models
let totalModels = 0;
for (const [providerId, providerData] of providerEntries) {
const prov = providerData as Record<string, unknown>;
await this.upsertProvider(providerId, prov);
const models = prov.models as Record<string, unknown> | undefined;
if (models && typeof models === 'object') {
totalModels += await this.upsertModels(providerId, models);
}
}
return { success: true, modelsUpdated: count };
return { success: true, modelsUpdated: totalModels };
} catch (error) {
return { success: false, modelsUpdated: 0, error: (error as Error).message };
}
@@ -140,10 +236,42 @@ export class ModelCatalogEngine {
// ── Internal ──
/**
* Parse models.dev model entries and upsert into database.
* Upsert a provider row.
*/
private async upsertProvider(id: string, data: Record<string, unknown>): Promise<void> {
const db = getDatabase().getLocal();
const now = new Date();
const env = Array.isArray(data.env) ? JSON.stringify(data.env) : null;
await db.insert(modelCatalogProviders)
.values({
id,
name: (data.name as string) || id,
env,
npm: (data.npm as string) || null,
api: (data.api as string) || null,
doc: (data.doc as string) || null,
updatedAt: now,
})
.onConflictDoUpdate({
target: modelCatalogProviders.id,
set: {
name: (data.name as string) || id,
env,
npm: (data.npm as string) || null,
api: (data.api as string) || null,
doc: (data.doc as string) || null,
updatedAt: now,
},
});
}
/**
* Parse and upsert model entries for a given provider.
* Also writes modality rows to the junction table.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async upsertModels(models: Record<string, any>): Promise<number> {
private async upsertModels(providerId: string, models: Record<string, any>): Promise<number> {
const db = getDatabase().getLocal();
const now = new Date();
let count = 0;
@@ -152,40 +280,87 @@ export class ModelCatalogEngine {
if (!info || typeof info !== 'object') continue;
const entry = {
id,
provider: providerId,
modelId: id,
name: info.name || id,
family: info.family || null,
contextWindow: info.limit?.context ?? null,
maxInputTokens: info.limit?.input ?? null,
maxOutputTokens: info.limit?.output ?? null,
attachment: info.attachment ?? false,
reasoning: info.reasoning ?? false,
toolCall: info.tool_call ?? false,
structuredOutput: info.structured_output ?? false,
temperature: info.temperature ?? false,
knowledge: info.knowledge || null,
releaseDate: info.release_date || null,
lastUpdatedDate: info.last_updated || null,
openWeights: info.open_weights ?? false,
inputPrice: info.cost?.input ?? null,
outputPrice: info.cost?.output ?? null,
cacheReadPrice: info.cost?.cache_read ?? null,
supportsAttachments: info.attachment ?? false,
supportsReasoning: info.reasoning ?? false,
supportsToolCall: info.tool_call ?? false,
cacheWritePrice: info.cost?.cache_write ?? null,
contextWindow: info.limit?.context ?? null,
maxInputTokens: info.limit?.input ?? null,
maxOutputTokens: info.limit?.output ?? null,
interleaved: info.interleaved ? JSON.stringify(info.interleaved) : null,
status: info.status || null,
providerNpm: info.provider?.npm || null,
updatedAt: now,
};
await db.insert(modelCatalog)
.values(entry)
.onConflictDoUpdate({
target: modelCatalog.id,
target: [modelCatalog.provider, modelCatalog.modelId],
set: {
name: entry.name,
family: entry.family,
contextWindow: entry.contextWindow,
maxInputTokens: entry.maxInputTokens,
maxOutputTokens: entry.maxOutputTokens,
attachment: entry.attachment,
reasoning: entry.reasoning,
toolCall: entry.toolCall,
structuredOutput: entry.structuredOutput,
temperature: entry.temperature,
knowledge: entry.knowledge,
releaseDate: entry.releaseDate,
lastUpdatedDate: entry.lastUpdatedDate,
openWeights: entry.openWeights,
inputPrice: entry.inputPrice,
outputPrice: entry.outputPrice,
cacheReadPrice: entry.cacheReadPrice,
supportsAttachments: entry.supportsAttachments,
supportsReasoning: entry.supportsReasoning,
supportsToolCall: entry.supportsToolCall,
cacheWritePrice: entry.cacheWritePrice,
contextWindow: entry.contextWindow,
maxInputTokens: entry.maxInputTokens,
maxOutputTokens: entry.maxOutputTokens,
interleaved: entry.interleaved,
status: entry.status,
providerNpm: entry.providerNpm,
updatedAt: now,
},
});
// Upsert modality tags
const mods = info.modalities;
if (mods && typeof mods === 'object') {
for (const direction of ['input', 'output'] as const) {
const tags = mods[direction];
if (Array.isArray(tags)) {
for (const modality of tags) {
if (typeof modality === 'string') {
await db.insert(modelCatalogModalities)
.values({ provider: providerId, modelId: id, direction, modality })
.onConflictDoUpdate({
target: [
modelCatalogModalities.provider,
modelCatalogModalities.modelId,
modelCatalogModalities.direction,
modelCatalogModalities.modality,
],
set: { modality }, // no-op update to satisfy ON CONFLICT
});
}
}
}
}
}
count++;
}
@@ -240,19 +415,38 @@ export class ModelCatalogEngine {
}
}
function toInfo(row: ModelCatalogEntry): ModelCatalogInfo {
// ── Helpers ──
/** Map of (provider, modelId) → { input: string[], output: string[] } for modalities */
type ModalityEntry = { provider: string; modelId: string; direction: string; modality: string };
function toInfo(row: ModelCatalogEntry, allModalities: ModalityEntry[]): ModelCatalogInfo {
const rowModalities = allModalities.filter(m => m.provider === row.provider && m.modelId === row.modelId);
return {
id: row.id,
provider: row.provider,
id: row.modelId,
name: row.name,
family: row.family,
contextWindow: row.contextWindow,
maxInputTokens: row.maxInputTokens,
maxOutputTokens: row.maxOutputTokens,
attachment: row.attachment ?? false,
reasoning: row.reasoning ?? false,
toolCall: row.toolCall ?? false,
structuredOutput: row.structuredOutput ?? false,
temperature: row.temperature ?? false,
knowledge: row.knowledge,
releaseDate: row.releaseDate,
lastUpdatedDate: row.lastUpdatedDate,
openWeights: row.openWeights ?? false,
inputPrice: row.inputPrice,
outputPrice: row.outputPrice,
cacheReadPrice: row.cacheReadPrice,
supportsAttachments: row.supportsAttachments,
supportsReasoning: row.supportsReasoning,
supportsToolCall: row.supportsToolCall,
cacheWritePrice: row.cacheWritePrice ?? null,
contextWindow: row.contextWindow,
maxInputTokens: row.maxInputTokens,
maxOutputTokens: row.maxOutputTokens,
interleaved: row.interleaved,
status: row.status,
providerNpm: row.providerNpm,
inputModalities: rowModalities.filter(m => m.direction === 'input').map(m => m.modality),
outputModalities: rowModalities.filter(m => m.direction === 'output').map(m => m.modality),
};
}

View File

@@ -38,99 +38,8 @@ const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
// Known model display names: maps model IDs to polished names and serves as offline fallback
const MODEL_DISPLAY_NAMES: Record<string, string> = {
// Anthropic Claude
'claude-opus-4-6': 'Claude Opus 4.6',
'claude-opus-4-5': 'Claude Opus 4.5',
'claude-opus-4-1': 'Claude Opus 4.1',
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
'claude-sonnet-4-5': 'Claude Sonnet 4.5',
'claude-sonnet-4': 'Claude Sonnet 4',
'claude-haiku-4-5': 'Claude Haiku 4.5',
'claude-3-5-haiku': 'Claude 3.5 Haiku',
// OpenAI GPT
'gpt-5.3-codex': 'GPT 5.3 Codex',
'gpt-5.2': 'GPT 5.2',
'gpt-5.2-codex': 'GPT 5.2 Codex',
'gpt-5.1': 'GPT 5.1',
'gpt-5.1-codex': 'GPT 5.1 Codex',
'gpt-5.1-codex-max': 'GPT 5.1 Codex Max',
'gpt-5.1-codex-mini': 'GPT 5.1 Codex Mini',
'gpt-5': 'GPT 5',
'gpt-5-codex': 'GPT 5 Codex',
'gpt-5-nano': 'GPT 5 Nano',
// Google Gemini
'gemini-3.1-pro': 'Gemini 3.1 Pro',
'gemini-3-pro': 'Gemini 3 Pro',
'gemini-3-flash': 'Gemini 3 Flash',
// Other providers
'glm-5': 'GLM 5',
'glm-5-free': 'GLM 5 Free',
'glm-4.7': 'GLM 4.7',
'glm-4.6': 'GLM 4.6',
'qwen3-coder': 'Qwen3 Coder',
'minimax-m2.5': 'MiniMax M2.5',
'minimax-m2.5-free': 'MiniMax M2.5 Free',
'minimax-m2.1': 'MiniMax M2.1',
'minimax-m2.1-free': 'MiniMax M2.1 Free',
'kimi-k2.5': 'Kimi K2.5',
'kimi-k2.5-free': 'Kimi K2.5 Free',
'kimi-k2': 'Kimi K2',
'kimi-k2-thinking': 'Kimi K2 Thinking',
'big-pickle': 'Big Pickle',
'trinity-large-preview-free': 'Trinity Large Preview Free',
// Mistral AI
'mistral-large-latest': 'Mistral Large',
'mistral-medium-latest': 'Mistral Medium',
'mistral-small-latest': 'Mistral Small',
'devstral-small-latest': 'Devstral Small',
'devstral-large-latest': 'Devstral Large',
};
// Uppercase prefixes that should not be title-cased
const UPPERCASE_PREFIXES = ['gpt', 'glm'];
// Per-model context token budgets for truncation
// OpenCode models default to 150,000; Mistral models have specific budgets
const MODEL_CONTEXT_BUDGETS: Record<string, number> = {
'mistral-large-latest': 35_000,
'mistral-medium-latest': 35_000,
'mistral-small-latest': 120_000,
'devstral-small-latest': 120_000,
'devstral-large-latest': 240_000,
};
// Vision capabilities per model (APIs don't expose this)
const MODEL_CAPABILITIES: Record<string, { vision: boolean }> = {
// Anthropic Claude — all vision-capable
'claude-opus-4-6': { vision: true },
'claude-opus-4-5': { vision: true },
'claude-opus-4-1': { vision: true },
'claude-sonnet-4-6': { vision: true },
'claude-sonnet-4-5': { vision: true },
'claude-sonnet-4': { vision: true },
'claude-haiku-4-5': { vision: true },
'claude-3-5-haiku': { vision: true },
// OpenAI GPT — most are vision-capable
'gpt-5': { vision: true },
'gpt-5.1': { vision: true },
'gpt-5.2': { vision: true },
'gpt-5-nano': { vision: true },
// Google Gemini — vision-capable
'gemini-3.1-pro': { vision: true },
'gemini-3-pro': { vision: true },
'gemini-3-flash': { vision: true },
// Mistral AI
'mistral-large-latest': { vision: true },
'mistral-medium-latest': { vision: true },
'mistral-small-latest': { vision: true },
'devstral-small-latest': { vision: false },
'devstral-large-latest': { vision: false },
};
export interface SendMessageOptions {
metadata?: {
surface?: 'tab' | 'sidebar';
@@ -303,6 +212,8 @@ export class OpenCodeManager {
{ 'x-api-key': apiKey },
];
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
for (const headers of attempts) {
try {
const response = await this.httpRequest(ZEN_MODELS_URL, {
@@ -310,10 +221,15 @@ export class OpenCodeManager {
headers,
});
if (response.statusCode >= 200 && response.statusCode < 300) {
// Filter to only OpenCode models (not Mistral)
const models = Object.entries(MODEL_DISPLAY_NAMES)
.map(([id, name]) => ({ id, name, provider: this.detectProvider(id), vision: MODEL_CAPABILITIES[id]?.vision ?? false }))
.filter(m => this.isProviderKeySet(m.provider));
const data = JSON.parse(response.body);
const models = (data.data && Array.isArray(data.data))
? (data.data as Array<{ id: string }>).map(m => ({
id: m.id,
name: this.resolveName(m.id, catalogNames),
provider: this.detectProvider(m.id),
vision: this.resolveVision(m.id, catalogVision),
}))
: [];
return { isValid: true, models };
}
} catch {
@@ -332,6 +248,8 @@ export class OpenCodeManager {
return { isValid: false, models: [] };
}
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
try {
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
method: 'GET',
@@ -343,10 +261,14 @@ export class OpenCodeManager {
if (response.statusCode >= 200 && response.statusCode < 300) {
const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data) && data.data.length > 0) {
// Return Mistral models from display name map
const models = Object.entries(MODEL_DISPLAY_NAMES)
.filter(([id]) => this.detectProvider(id) === 'mistral')
.map(([id, name]) => ({ id, name, provider: 'mistral', vision: MODEL_CAPABILITIES[id]?.vision ?? false }));
const models = (data.data as Array<{ id: string }>)
.filter(m => this.detectProvider(m.id) === 'mistral')
.map(m => ({
id: m.id,
name: this.resolveName(m.id, catalogNames),
provider: 'mistral',
vision: this.resolveVision(m.id, catalogVision),
}));
return { isValid: true, models };
}
}
@@ -370,6 +292,9 @@ export class OpenCodeManager {
const allModels: ChatModel[] = [];
let fetched = false;
// Load catalog for vision + name cross-referencing
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
// Fetch OpenCode models
if (this.apiKey) {
try {
@@ -386,9 +311,9 @@ export class OpenCodeManager {
for (const m of data.data as Array<{ id: string }>) {
allModels.push({
id: m.id,
name: this.formatModelName(m.id),
name: this.resolveName(m.id, catalogNames),
provider: this.detectProvider(m.id),
vision: MODEL_CAPABILITIES[m.id]?.vision ?? false,
vision: this.resolveVision(m.id, catalogVision),
});
}
fetched = true;
@@ -412,13 +337,12 @@ export class OpenCodeManager {
const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data)) {
for (const m of data.data as Array<{ id: string }>) {
// Only include models we know about (have display names)
if (MODEL_DISPLAY_NAMES[m.id]) {
if (this.detectProvider(m.id) === 'mistral') {
allModels.push({
id: m.id,
name: this.formatModelName(m.id),
name: this.resolveName(m.id, catalogNames),
provider: 'mistral',
vision: MODEL_CAPABILITIES[m.id]?.vision ?? false,
vision: this.resolveVision(m.id, catalogVision),
});
}
}
@@ -436,16 +360,23 @@ export class OpenCodeManager {
return allModels;
}
// Build fallback from display name map, filtered by available provider keys
const fallback = Object.entries(MODEL_DISPLAY_NAMES)
.map(([id, name]) => ({
id,
name,
provider: this.detectProvider(id),
vision: MODEL_CAPABILITIES[id]?.vision ?? false,
}))
.filter(m => this.isProviderKeySet(m.provider));
return fallback;
// Fallback: build from model catalog database (models.dev), filtered by available provider keys
try {
const catalog = await this.modelCatalogEngine.getAll();
if (catalog.length > 0) {
return catalog
.map(m => ({
id: m.id,
name: m.name,
provider: this.detectProvider(m.id),
vision: m.inputModalities.includes('image'),
}))
.filter(m => this.isProviderKeySet(m.provider));
}
} catch {
// Fall through to empty
}
return [];
}
/**
@@ -943,7 +874,7 @@ export class OpenCodeManager {
// Truncate conversation history to fit within context window
// Keep system message (index 0), truncate from oldest conversation messages
const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000;
const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000;
const conversationMessages = allMessages.slice(1);
const anthropicFmt = conversationMessages.map(m => ({
role: m.role as 'user' | 'assistant',
@@ -2245,6 +2176,40 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
return !!this.apiKey;
}
/**
* Load model catalog into maps for quick vision and name lookups.
* Vision = model has 'image' in its input modalities.
*/
private async getCatalogLookups(): Promise<{ vision: Map<string, boolean>; names: Map<string, string> }> {
const vision = new Map<string, boolean>();
const names = new Map<string, string>();
try {
const catalog = await this.modelCatalogEngine.getAll();
for (const m of catalog) {
vision.set(m.id, m.inputModalities.includes('image'));
names.set(m.id, m.name);
}
} catch {
// Catalog unavailable — maps stay empty
}
return { vision, names };
}
/**
* Resolve vision capability for a model ID.
* Vision = 'image' is in the model's input modalities from the catalog.
*/
private resolveVision(modelId: string, catalogVision: Map<string, boolean>): boolean {
return catalogVision.get(modelId) ?? false;
}
/**
* Resolve display name for a model ID. Falls back to raw model ID.
*/
private resolveName(modelId: string, catalogNames: Map<string, string>): string {
return catalogNames.get(modelId) ?? modelId;
}
/**
* Return API URL, key and provider-specific options for a given provider.
* Used to parameterise sendOpenAIMessage() for non-Anthropic providers.
@@ -2265,24 +2230,7 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
return 'other';
}
private formatModelName(modelId: string): string {
// Check display name map first
if (MODEL_DISPLAY_NAMES[modelId]) {
return MODEL_DISPLAY_NAMES[modelId];
}
// Auto-format: split on hyphens, handle uppercase prefixes and version dots
const words = modelId.split('-');
return words
.map((word, index) => {
// First word: check for uppercase prefixes
if (index === 0 && UPPERCASE_PREFIXES.includes(word.toLowerCase())) {
return word.toUpperCase();
}
// Capitalize first letter
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
private parseErrorResponse(response: HttpResponse): string {
let errorMsg = `API error: ${response.statusCode}`;