feat: integration of models.dev and proper handling of outpout tokens

This commit is contained in:
2026-03-01 14:04:23 +01:00
parent 6891613943
commit 1dd520f770
18 changed files with 2101 additions and 14 deletions

View File

@@ -0,0 +1,20 @@
CREATE TABLE `model_catalog` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`family` text,
`context_window` integer,
`max_input_tokens` integer,
`max_output_tokens` integer,
`input_price` real,
`output_price` real,
`cache_read_price` real,
`supports_attachments` integer DEFAULT false,
`supports_reasoning` integer DEFAULT false,
`supports_tool_call` integer DEFAULT false,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `model_catalog_meta` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1772301340810, "when": 1772301340810,
"tag": "0007_closed_sabretooth", "tag": "0007_closed_sabretooth",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1772369331600,
"tag": "0008_third_cable",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core';
// Projects table - stores blog projects/websites // Projects table - stores blog projects/websites
export const projects = sqliteTable('projects', { export const projects = sqliteTable('projects', {
@@ -206,6 +206,31 @@ export const dbNotifications = sqliteTable('db_notifications', {
createdAt: integer('created_at').notNull(), createdAt: integer('created_at').notNull(),
}); });
// Model catalog table - cached model metadata from models.dev API
// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider.
// Refreshed on user action via conditional GET (ETag). Survives offline use.
export const modelCatalog = sqliteTable('model_catalog', {
id: text('id').primaryKey(), // model ID (e.g. 'claude-sonnet-4-5')
name: text('name').notNull(), // display name
family: text('family'), // model family (e.g. 'claude-sonnet')
contextWindow: integer('context_window'), // max context tokens
maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context)
maxOutputTokens: integer('max_output_tokens'), // max output tokens
inputPrice: real('input_price'), // cost per 1M input tokens (USD)
outputPrice: real('output_price'), // cost per 1M output tokens (USD)
cacheReadPrice: real('cache_read_price'), // cost per 1M cached input tokens (USD)
supportsAttachments: integer('supports_attachments', { mode: 'boolean' }).default(false),
supportsReasoning: integer('supports_reasoning', { mode: 'boolean' }).default(false),
supportsToolCall: integer('supports_tool_call', { mode: 'boolean' }).default(false),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Model catalog HTTP cache metadata (ETag for conditional GET)
export const modelCatalogMeta = sqliteTable('model_catalog_meta', {
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
value: text('value').notNull(),
});
// Types for TypeScript // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -235,3 +260,7 @@ export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert; export type NewTemplate = typeof templates.$inferInsert;
export type DbNotification = typeof dbNotifications.$inferSelect; export type DbNotification = typeof dbNotifications.$inferSelect;
export type NewDbNotification = typeof dbNotifications.$inferInsert; export type NewDbNotification = typeof dbNotifications.$inferInsert;
export type ModelCatalogEntry = typeof modelCatalog.$inferSelect;
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;

View File

@@ -0,0 +1,258 @@
/**
* 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.
*
* Data is persisted in SQLite (model_catalog + model_catalog_meta tables)
* and 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 { getDatabase } from '../database';
import { modelCatalog, modelCatalogMeta } 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;
export interface ModelCatalogInfo {
id: string;
name: string;
family: string | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
inputPrice: number | null;
outputPrice: number | null;
cacheReadPrice: number | null;
supportsAttachments: boolean | null;
supportsReasoning: boolean | null;
supportsToolCall: boolean | null;
}
export interface RefreshResult {
success: boolean;
modelsUpdated: number;
notModified?: boolean;
error?: string;
}
interface HttpResponse {
statusCode: number;
body: string;
headers: Record<string, string | string[] | undefined>;
}
export class ModelCatalogEngine {
/**
* Get all cached model catalog entries from the database.
*/
async getAll(): Promise<ModelCatalogInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog);
return rows.map(toInfo);
}
/**
* Get a single model's catalog entry by ID.
*/
async getModel(modelId: string): Promise<ModelCatalogInfo | null> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId));
return rows.length > 0 ? toInfo(rows[0]) : null;
}
/**
* 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);
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* 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.
*/
async refresh(): Promise<RefreshResult> {
try {
// Read stored ETag for conditional GET
const storedEtag = await this.getMeta('etag');
// Build request headers
const headers: Record<string, string> = { 'Accept': 'application/json' };
if (storedEtag) {
headers['If-None-Match'] = storedEtag;
}
// Fetch from models.dev
const response = await this.httpGet(MODELS_DEV_URL, headers);
// 304 Not Modified — data hasn't changed
if (response.statusCode === 304) {
await this.setMeta('lastFetchedAt', new Date().toISOString());
return { success: true, modelsUpdated: 0, notModified: true };
}
if (response.statusCode !== 200) {
return { success: false, modelsUpdated: 0, error: `HTTP ${response.statusCode}` };
}
// 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' };
}
// Store new ETag
const newEtag = response.headers['etag'];
if (typeof newEtag === 'string') {
await this.setMeta('etag', newEtag);
}
await this.setMeta('lastFetchedAt', new Date().toISOString());
// Upsert all models
const count = await this.upsertModels(models);
return { success: true, modelsUpdated: count };
} catch (error) {
return { success: false, modelsUpdated: 0, error: (error as Error).message };
}
}
/**
* Get the last time the catalog was successfully fetched.
*/
async getLastFetchedAt(): Promise<string | null> {
return this.getMeta('lastFetchedAt');
}
// ── Internal ──
/**
* Parse models.dev model entries and upsert into database.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async upsertModels(models: Record<string, any>): Promise<number> {
const db = getDatabase().getLocal();
const now = new Date();
let count = 0;
for (const [id, info] of Object.entries(models)) {
if (!info || typeof info !== 'object') continue;
const entry = {
id,
name: info.name || id,
family: info.family || null,
contextWindow: info.limit?.context ?? null,
maxInputTokens: info.limit?.input ?? null,
maxOutputTokens: info.limit?.output ?? null,
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,
updatedAt: now,
};
await db.insert(modelCatalog)
.values(entry)
.onConflictDoUpdate({
target: modelCatalog.id,
set: {
name: entry.name,
family: entry.family,
contextWindow: entry.contextWindow,
maxInputTokens: entry.maxInputTokens,
maxOutputTokens: entry.maxOutputTokens,
inputPrice: entry.inputPrice,
outputPrice: entry.outputPrice,
cacheReadPrice: entry.cacheReadPrice,
supportsAttachments: entry.supportsAttachments,
supportsReasoning: entry.supportsReasoning,
supportsToolCall: entry.supportsToolCall,
updatedAt: now,
},
});
count++;
}
return count;
}
private async getMeta(key: string): Promise<string | null> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalogMeta).where(eq(modelCatalogMeta.key, key));
return rows.length > 0 ? rows[0].value : null;
}
private async setMeta(key: string, value: string): Promise<void> {
const db = getDatabase().getLocal();
await db.insert(modelCatalogMeta)
.values({ key, value })
.onConflictDoUpdate({ target: modelCatalogMeta.key, set: { value } });
}
private httpGet(
urlStr: string,
headers: Record<string, string>,
): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const protocol = url.protocol === 'https:' ? https : http;
const req = protocol.request(url, {
method: 'GET',
headers,
timeout: 15000,
}, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk; });
res.on('end', () => {
resolve({
statusCode: res.statusCode || 0,
body,
headers: res.headers as Record<string, string | string[] | undefined>,
});
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
req.end();
});
}
}
function toInfo(row: ModelCatalogEntry): ModelCatalogInfo {
return {
id: row.id,
name: row.name,
family: row.family,
contextWindow: row.contextWindow,
maxInputTokens: row.maxInputTokens,
maxOutputTokens: row.maxOutputTokens,
inputPrice: row.inputPrice,
outputPrice: row.outputPrice,
cacheReadPrice: row.cacheReadPrice,
supportsAttachments: row.supportsAttachments,
supportsReasoning: row.supportsReasoning,
supportsToolCall: row.supportsToolCall,
};
}

View File

@@ -24,6 +24,7 @@ import { ChatEngine } from './ChatEngine';
import { PostEngine, type PostData } from './PostEngine'; import { PostEngine, type PostData } from './PostEngine';
import { MediaEngine, type MediaData } from './MediaEngine'; import { MediaEngine, type MediaData } from './MediaEngine';
import type { PostMediaEngine } from './PostMediaEngine'; import type { PostMediaEngine } from './PostMediaEngine';
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine';
import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
import type { A2UIServerMessage } from '../a2ui/types'; import type { A2UIServerMessage } from '../a2ui/types';
@@ -76,6 +77,8 @@ const MODEL_DISPLAY_NAMES: Record<string, string> = {
'trinity-large-preview-free': 'Trinity Large Preview Free', 'trinity-large-preview-free': 'Trinity Large Preview Free',
}; };
// Uppercase prefixes that should not be title-cased // Uppercase prefixes that should not be title-cased
const UPPERCASE_PREFIXES = ['gpt', 'glm']; const UPPERCASE_PREFIXES = ['gpt', 'glm'];
@@ -172,6 +175,7 @@ export class OpenCodeManager {
private cachedModels: ModelInfo[] | null = null; private cachedModels: ModelInfo[] | null = null;
private cachedModelsAt: number = 0; private cachedModelsAt: number = 0;
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private modelCatalogEngine = new ModelCatalogEngine();
private conversationUsage: Map<string, { private conversationUsage: Map<string, {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
@@ -477,7 +481,7 @@ export class OpenCodeManager {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: modelId, model: modelId,
max_tokens: 4096, max_tokens: await this.getMaxOutputTokens(modelId),
system: systemPrompt, system: systemPrompt,
messages, messages,
tools, tools,
@@ -793,7 +797,7 @@ export class OpenCodeManager {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: modelId, model: modelId,
max_tokens: 4096, max_tokens: await this.getMaxOutputTokens(modelId),
messages, messages,
tools: openaiTools, tools: openaiTools,
stream: true, stream: true,
@@ -1977,6 +1981,21 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
} }
} }
/**
* Get max output tokens for a model from the model catalog (DB-backed).
* Falls back to DEFAULT_MAX_OUTPUT_TOKENS (16384) when not catalogued.
*/
private async getMaxOutputTokens(modelId: string): Promise<number> {
return this.modelCatalogEngine.getMaxOutputTokens(modelId);
}
/**
* Access the model catalog engine (used by IPC handlers).
*/
getModelCatalogEngine(): ModelCatalogEngine {
return this.modelCatalogEngine;
}
private detectProvider(modelId: string): string { private detectProvider(modelId: string): string {
const id = modelId.toLowerCase(); const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic'; if (id.startsWith('claude')) return 'anthropic';

View File

@@ -212,6 +212,32 @@ export function registerChatHandlers(): void {
} }
}); });
// ============ Model Catalog ============
// Refresh model catalog from models.dev (conditional GET with ETag)
ipcMain.handle('chat:refreshModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const result = await manager.getModelCatalogEngine().refresh();
return result;
} catch (error) {
console.error('[Chat IPC] Error refreshing model catalog:', error);
return { success: false, modelsUpdated: 0, error: (error as Error).message };
}
});
// Get all model catalog entries
ipcMain.handle('chat:getModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const entries = await manager.getModelCatalogEngine().getAll();
return { success: true, entries };
} catch (error) {
console.error('[Chat IPC] Error getting model catalog:', error);
return { success: false, entries: [], error: (error as Error).message };
}
});
// ============ Conversation CRUD ============ // ============ Conversation CRUD ============
// Get all conversations // Get all conversations

View File

@@ -315,6 +315,10 @@ export const electronAPI: ElectronAPI = {
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'), getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt), setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt),
// Model Catalog
refreshModelCatalog: () => ipcRenderer.invoke('chat:refreshModelCatalog'),
getModelCatalog: () => ipcRenderer.invoke('chat:getModelCatalog'),
// Conversations // Conversations
getConversations: () => ipcRenderer.invoke('chat:getConversations'), getConversations: () => ipcRenderer.invoke('chat:getConversations'),
createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model), createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model),

View File

@@ -424,6 +424,28 @@ export interface ChatModel {
provider?: string; provider?: string;
} }
export interface ModelCatalogEntry {
id: string;
name: string;
family: string | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
inputPrice: number | null;
outputPrice: number | null;
cacheReadPrice: number | null;
supportsAttachments: boolean | null;
supportsReasoning: boolean | null;
supportsToolCall: boolean | null;
}
export interface ModelCatalogRefreshResult {
success: boolean;
modelsUpdated: number;
notModified?: boolean;
error?: string;
}
export interface ChatReadyStatus { export interface ChatReadyStatus {
ready: boolean; ready: boolean;
error?: string; error?: string;
@@ -809,6 +831,10 @@ export interface ElectronAPI {
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>; setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>;
// Model Catalog
refreshModelCatalog: () => Promise<ModelCatalogRefreshResult>;
getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>;
// Conversations // Conversations
getConversations: () => Promise<ChatConversation[]>; getConversations: () => Promise<ChatConversation[]>;
createConversation: (title?: string, model?: string) => Promise<ChatConversation>; createConversation: (title?: string, model?: string) => Promise<ChatConversation>;

View File

@@ -242,6 +242,18 @@
flex: 1; flex: 1;
} }
.setting-input-group select {
flex: 1;
}
/* Model catalog metadata line */
.model-catalog-info {
font-size: 11px;
color: var(--vscode-descriptionForeground, #888);
padding: 4px 0 0;
line-height: 1.4;
}
.setting-toggle-visibility { .setting-toggle-visibility {
background: transparent; background: transparent;
border: none; border: none;

View File

@@ -244,6 +244,13 @@ export const SettingsView: React.FC = () => {
const [newApiKey, setNewApiKey] = useState(''); const [newApiKey, setNewApiKey] = useState('');
const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]); const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]);
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [modelCatalog, setModelCatalog] = useState<Map<string, {
maxOutputTokens: number | null;
contextWindow: number | null;
inputPrice: number | null;
outputPrice: number | null;
}>>(new Map());
const [refreshingCatalog, setRefreshingCatalog] = useState(false);
// Check if a section has any matching settings // Check if a section has any matching settings
const sectionHasMatches = useCallback((sectionKeywords: string[]) => { const sectionHasMatches = useCallback((sectionKeywords: string[]) => {
@@ -395,6 +402,21 @@ export const SettingsView: React.FC = () => {
setAvailableModels(modelsResult.models); setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || ''); setSelectedModel(modelsResult.selectedModel || '');
} }
// Load model catalog metadata
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
if (catalogResult?.success && catalogResult.entries) {
const map = new Map<string, { maxOutputTokens: number | null; contextWindow: number | null; inputPrice: number | null; outputPrice: number | null }>();
for (const entry of catalogResult.entries) {
map.set(entry.id, {
maxOutputTokens: entry.maxOutputTokens,
contextWindow: entry.contextWindow,
inputPrice: entry.inputPrice,
outputPrice: entry.outputPrice,
});
}
setModelCatalog(map);
}
} catch (error) { } catch (error) {
console.error('Failed to load AI settings:', error); console.error('Failed to load AI settings:', error);
} }
@@ -1080,6 +1102,41 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleRefreshModelCatalog = async () => {
setRefreshingCatalog(true);
try {
const result = await window.electronAPI?.chat.refreshModelCatalog();
if (result?.success) {
if (result.notModified) {
showToast.success(t('settings.toast.modelCatalogUpToDate'));
} else {
showToast.success(t('settings.toast.modelCatalogRefreshed', { count: String(result.modelsUpdated) }));
}
// Reload catalog data
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
if (catalogResult?.success && catalogResult.entries) {
const map = new Map<string, { maxOutputTokens: number | null; contextWindow: number | null; inputPrice: number | null; outputPrice: number | null }>();
for (const entry of catalogResult.entries) {
map.set(entry.id, {
maxOutputTokens: entry.maxOutputTokens,
contextWindow: entry.contextWindow,
inputPrice: entry.inputPrice,
outputPrice: entry.outputPrice,
});
}
setModelCatalog(map);
}
} else {
showToast.error(t('settings.toast.modelCatalogRefreshFailed'));
}
} catch (error) {
console.error('Failed to refresh model catalog:', error);
showToast.error(t('settings.toast.modelCatalogRefreshFailed'));
} finally {
setRefreshingCatalog(false);
}
};
const renderAISettings = () => ( const renderAISettings = () => (
<SettingSection <SettingSection
id="settings-section-ai" id="settings-section-ai"
@@ -1133,6 +1190,7 @@ export const SettingsView: React.FC = () => {
label={t('settings.ai.defaultModelLabel')} label={t('settings.ai.defaultModelLabel')}
description={t('settings.ai.defaultModelDescription')} description={t('settings.ai.defaultModelDescription')}
> >
<div className="setting-input-group">
<select <select
id="ai-model" id="ai-model"
value={selectedModel} value={selectedModel}
@@ -1144,6 +1202,34 @@ export const SettingsView: React.FC = () => {
<option key={model.id} value={model.id}>{model.name}</option> <option key={model.id} value={model.id}>{model.name}</option>
))} ))}
</select> </select>
<button
className="secondary"
onClick={handleRefreshModelCatalog}
disabled={refreshingCatalog || !aiHasApiKey}
title={t('settings.ai.refreshModelCatalog')}
>
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
</button>
</div>
{selectedModel && modelCatalog.has(selectedModel) && (() => {
const info = modelCatalog.get(selectedModel)!;
const parts: string[] = [];
if (info.maxOutputTokens != null) {
parts.push(`${t('settings.ai.modelInfoMaxOutput')}: ${info.maxOutputTokens.toLocaleString()} ${t('settings.ai.modelInfoTokens')}`);
}
if (info.contextWindow != null) {
parts.push(`${t('settings.ai.modelInfoContext')}: ${info.contextWindow.toLocaleString()} ${t('settings.ai.modelInfoTokens')}`);
}
if (info.inputPrice != null) {
parts.push(`${t('settings.ai.modelInfoInputPrice')}: $${info.inputPrice}${t('settings.ai.modelInfoPerMTok')}`);
}
if (info.outputPrice != null) {
parts.push(`${t('settings.ai.modelInfoOutputPrice')}: $${info.outputPrice}${t('settings.ai.modelInfoPerMTok')}`);
}
return parts.length > 0 ? (
<div className="model-catalog-info">{parts.join(' · ')}</div>
) : null;
})()}
</SettingRow> </SettingRow>
<SettingRow <SettingRow

View File

@@ -725,6 +725,17 @@
"settings.ai.systemPromptPlaceholder": "Systemanweisungen für den KI-Assistenten eingeben...", "settings.ai.systemPromptPlaceholder": "Systemanweisungen für den KI-Assistenten eingeben...",
"settings.ai.savePrompt": "Prompt speichern", "settings.ai.savePrompt": "Prompt speichern",
"settings.ai.resetPrompt": "Auf Standard zurücksetzen", "settings.ai.resetPrompt": "Auf Standard zurücksetzen",
"settings.ai.refreshModelCatalog": "Modellkatalog aktualisieren",
"settings.ai.refreshing": "Wird aktualisiert…",
"settings.ai.modelInfoMaxOutput": "Max. Ausgabe",
"settings.ai.modelInfoContext": "Kontext",
"settings.ai.modelInfoInputPrice": "Eingabe",
"settings.ai.modelInfoOutputPrice": "Ausgabe",
"settings.ai.modelInfoTokens": "Token",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
"settings.publishing.sshHostDescription": "Hostname oder IP-Adresse des SSH-Servers.", "settings.publishing.sshHostDescription": "Hostname oder IP-Adresse des SSH-Servers.",
"settings.publishing.sshUsernameDescription": "Benutzername deines SSH-Kontos.", "settings.publishing.sshUsernameDescription": "Benutzername deines SSH-Kontos.",
"settings.publishing.sshRemotePathDescription": "Das Zielverzeichnis auf dem Remote-Server, in das dein Blog veröffentlicht wird.", "settings.publishing.sshRemotePathDescription": "Das Zielverzeichnis auf dem Remote-Server, in das dein Blog veröffentlicht wird.",

View File

@@ -725,6 +725,17 @@
"settings.ai.systemPromptPlaceholder": "Enter system instructions for the AI assistant...", "settings.ai.systemPromptPlaceholder": "Enter system instructions for the AI assistant...",
"settings.ai.savePrompt": "Save Prompt", "settings.ai.savePrompt": "Save Prompt",
"settings.ai.resetPrompt": "Reset to Default", "settings.ai.resetPrompt": "Reset to Default",
"settings.ai.refreshModelCatalog": "Refresh Model Catalog",
"settings.ai.refreshing": "Refreshing…",
"settings.ai.modelInfoMaxOutput": "Max output",
"settings.ai.modelInfoContext": "Context",
"settings.ai.modelInfoInputPrice": "Input",
"settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
"settings.publishing.sshHostDescription": "The SSH server hostname or IP address.", "settings.publishing.sshHostDescription": "The SSH server hostname or IP address.",
"settings.publishing.sshUsernameDescription": "Your SSH account username.", "settings.publishing.sshUsernameDescription": "Your SSH account username.",
"settings.publishing.sshRemotePathDescription": "The destination directory on the remote server where your blog will be published.", "settings.publishing.sshRemotePathDescription": "The destination directory on the remote server where your blog will be published.",

View File

@@ -725,6 +725,17 @@
"settings.ai.systemPromptPlaceholder": "Escribe aquí tu prompt del sistema…", "settings.ai.systemPromptPlaceholder": "Escribe aquí tu prompt del sistema…",
"settings.ai.savePrompt": "Guardar prompt", "settings.ai.savePrompt": "Guardar prompt",
"settings.ai.resetPrompt": "Restablecer prompt", "settings.ai.resetPrompt": "Restablecer prompt",
"settings.ai.refreshModelCatalog": "Actualizar catálogo de modelos",
"settings.ai.refreshing": "Actualizando…",
"settings.ai.modelInfoMaxOutput": "Salida máx.",
"settings.ai.modelInfoContext": "Contexto",
"settings.ai.modelInfoInputPrice": "Entrada",
"settings.ai.modelInfoOutputPrice": "Salida",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
"settings.publishing.sshHostDescription": "Nombre de host o IP del servidor SSH.", "settings.publishing.sshHostDescription": "Nombre de host o IP del servidor SSH.",
"settings.publishing.sshUsernameDescription": "Nombre de usuario de SSH.", "settings.publishing.sshUsernameDescription": "Nombre de usuario de SSH.",
"settings.publishing.sshRemotePathDescription": "El directorio de destino en el servidor remoto donde se publicará tu blog.", "settings.publishing.sshRemotePathDescription": "El directorio de destino en el servidor remoto donde se publicará tu blog.",

View File

@@ -723,6 +723,17 @@
"settings.ai.systemPromptPlaceholder": "Écrivez votre prompt système ici…", "settings.ai.systemPromptPlaceholder": "Écrivez votre prompt système ici…",
"settings.ai.savePrompt": "Enregistrer le prompt", "settings.ai.savePrompt": "Enregistrer le prompt",
"settings.ai.resetPrompt": "Réinitialiser le prompt", "settings.ai.resetPrompt": "Réinitialiser le prompt",
"settings.ai.refreshModelCatalog": "Actualiser le catalogue",
"settings.ai.refreshing": "Actualisation…",
"settings.ai.modelInfoMaxOutput": "Sortie max.",
"settings.ai.modelInfoContext": "Contexte",
"settings.ai.modelInfoInputPrice": "Entrée",
"settings.ai.modelInfoOutputPrice": "Sortie",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
"settings.publishing.sshHostDescription": "Nom d'hôte ou IP du serveur SSH.", "settings.publishing.sshHostDescription": "Nom d'hôte ou IP du serveur SSH.",
"settings.publishing.sshUsernameDescription": "Nom d'utilisateur SSH.", "settings.publishing.sshUsernameDescription": "Nom d'utilisateur SSH.",
"settings.publishing.sshRemotePathDescription": "Le répertoire de destination sur le serveur distant où votre blog sera publié.", "settings.publishing.sshRemotePathDescription": "Le répertoire de destination sur le serveur distant où votre blog sera publié.",

View File

@@ -723,6 +723,17 @@
"settings.ai.systemPromptPlaceholder": "Scrivi qui il tuo prompt di sistema…", "settings.ai.systemPromptPlaceholder": "Scrivi qui il tuo prompt di sistema…",
"settings.ai.savePrompt": "Salva prompt", "settings.ai.savePrompt": "Salva prompt",
"settings.ai.resetPrompt": "Reimposta prompt", "settings.ai.resetPrompt": "Reimposta prompt",
"settings.ai.refreshModelCatalog": "Aggiorna catalogo modelli",
"settings.ai.refreshing": "Aggiornamento…",
"settings.ai.modelInfoMaxOutput": "Output max.",
"settings.ai.modelInfoContext": "Contesto",
"settings.ai.modelInfoInputPrice": "Input",
"settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "token",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
"settings.publishing.sshHostDescription": "Hostname o IP del server SSH.", "settings.publishing.sshHostDescription": "Hostname o IP del server SSH.",
"settings.publishing.sshUsernameDescription": "Nome utente SSH.", "settings.publishing.sshUsernameDescription": "Nome utente SSH.",
"settings.publishing.sshRemotePathDescription": "La directory di destinazione sul server remoto in cui verrà pubblicato il tuo blog.", "settings.publishing.sshRemotePathDescription": "La directory di destinazione sul server remoto in cui verrà pubblicato il tuo blog.",

View File

@@ -0,0 +1,276 @@
/**
* ModelCatalogEngine Tests
*
* Tests the model catalog engine that fetches and caches
* model metadata from models.dev for the OpenCode provider.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// ── Chainable Drizzle mock with onConflictDoUpdate ──
function createSelectChain(mockData: unknown[] = []) {
const chain: Record<string, unknown> = {
from: vi.fn().mockImplementation(() => chain),
where: vi.fn().mockImplementation(() => chain),
orderBy: vi.fn().mockImplementation(() => chain),
then: (resolve: (v: unknown) => void) => Promise.resolve(mockData).then(resolve),
};
return chain;
}
let selectMockData: unknown[] = [];
const insertedValues: unknown[] = [];
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain(selectMockData)),
insert: vi.fn(() => ({
values: vi.fn((data: unknown) => {
insertedValues.push(data);
return {
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
then: (resolve: (v: unknown) => void) => Promise.resolve().then(resolve),
};
}),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
})),
}));
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
// ── Sample models.dev response ──
function sampleModelsDevResponse() {
return {
opencode: {
id: 'opencode',
models: {
'claude-sonnet-4-5': {
id: 'claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
family: 'claude-sonnet',
attachment: true,
reasoning: false,
tool_call: true,
cost: { input: 3, output: 15, cache_read: 0.3 },
limit: { context: 200000, output: 64000 },
},
'gpt-5': {
id: 'gpt-5',
name: 'GPT 5',
family: 'gpt',
attachment: true,
reasoning: true,
tool_call: true,
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
limit: { context: 400000, input: 272000, output: 128000 },
},
'model-no-cost': {
id: 'model-no-cost',
name: 'Free Model',
family: 'free',
limit: { context: 32000, output: 4096 },
},
},
},
};
}
describe('ModelCatalogEngine', () => {
let engine: ModelCatalogEngine;
beforeEach(() => {
vi.clearAllMocks();
selectMockData = [];
insertedValues.length = 0;
engine = new ModelCatalogEngine();
});
describe('getAll', () => {
it('returns all cached model catalog entries', async () => {
selectMockData = [
{
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
},
];
const result = await engine.getAll();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('claude-sonnet-4-5');
expect(result[0].maxOutputTokens).toBe(64000);
expect(result[0].inputPrice).toBe(3);
});
it('returns empty array when no catalog entries exist', async () => {
selectMockData = [];
const result = await engine.getAll();
expect(result).toEqual([]);
});
});
describe('getModel', () => {
it('returns a specific model by ID', async () => {
selectMockData = [{
id: 'gpt-5', name: 'GPT 5', family: 'gpt',
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107,
supportsAttachments: true, supportsReasoning: true, supportsToolCall: true,
}];
const result = await engine.getModel('gpt-5');
expect(result).not.toBeNull();
expect(result!.name).toBe('GPT 5');
expect(result!.maxOutputTokens).toBe(128000);
});
it('returns null for unknown model', async () => {
selectMockData = [];
const result = await engine.getModel('nonexistent');
expect(result).toBeNull();
});
});
describe('getMaxOutputTokens', () => {
it('returns output tokens from catalog when available', async () => {
selectMockData = [{
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
}];
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
expect(result).toBe(64000);
});
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
selectMockData = [];
const result = await engine.getMaxOutputTokens('unknown-model');
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
selectMockData = [{
id: 'weird-model', name: 'Weird', family: null,
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
inputPrice: null, outputPrice: null, cacheReadPrice: null,
supportsAttachments: false, supportsReasoning: false, supportsToolCall: false,
}];
const result = await engine.getMaxOutputTokens('weird-model');
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
});
describe('refresh', () => {
it('parses models.dev response and inserts models into DB', async () => {
const mockResponse = sampleModelsDevResponse();
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 200,
body: JSON.stringify(mockResponse),
headers: { etag: '"abc123"' },
});
// getMeta returns null (no existing etag)
selectMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(true);
expect(result.modelsUpdated).toBe(3);
expect(result.notModified).toBeUndefined();
});
it('sends If-None-Match header when ETag is cached', async () => {
const httpGetSpy = vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 304,
body: '',
headers: {},
});
// Return stored etag on first getMeta call
let metaCallCount = 0;
const origSelect = mockLocalDb.select;
mockLocalDb.select = vi.fn(() => {
metaCallCount++;
if (metaCallCount === 1) {
return createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
}
return createSelectChain([]);
}) as any;
const result = await engine.refresh();
expect(result.success).toBe(true);
expect(result.notModified).toBe(true);
expect(httpGetSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ 'If-None-Match': '"old-etag"' }),
);
mockLocalDb.select = origSelect;
});
it('handles HTTP errors gracefully', async () => {
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 500,
body: 'Internal Server Error',
headers: {},
});
selectMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 500');
});
it('handles network errors gracefully', async () => {
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
selectMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
});
it('handles invalid response (missing opencode provider)', async () => {
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 200,
body: JSON.stringify({ other_provider: { models: {} } }),
headers: {},
});
selectMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toContain('no opencode models');
});
it('handles malformed JSON gracefully', async () => {
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 200,
body: 'not valid json {{{',
headers: {},
});
selectMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});

View File

@@ -200,3 +200,35 @@ describe('OpenCodeManager tool execution backlinks & linksTo', () => {
}); });
}); });
}); });
describe('OpenCodeManager getMaxOutputTokens (ModelCatalogEngine delegate)', () => {
let manager: OpenCodeManager;
beforeEach(() => {
vi.clearAllMocks();
manager = createManager(createMockPostEngine());
});
it('delegates to ModelCatalogEngine.getMaxOutputTokens', async () => {
const engine = (manager as any).modelCatalogEngine;
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(64000);
const result = await (manager as any).getMaxOutputTokens('claude-sonnet-4-5');
expect(result).toBe(64000);
expect(engine.getMaxOutputTokens).toHaveBeenCalledWith('claude-sonnet-4-5');
});
it('returns default when ModelCatalogEngine has no data', async () => {
const engine = (manager as any).modelCatalogEngine;
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(16384);
const result = await (manager as any).getMaxOutputTokens('unknown-model');
expect(result).toBe(16384);
});
it('exposes ModelCatalogEngine via getModelCatalogEngine()', () => {
const engine = manager.getModelCatalogEngine();
expect(engine).toBeDefined();
expect(engine).toBeInstanceOf(Object);
});
});