feat: integration of models.dev and proper handling of outpout tokens
This commit is contained in:
20
drizzle/0008_third_cable.sql
Normal file
20
drizzle/0008_third_cable.sql
Normal 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
|
||||||
|
);
|
||||||
1237
drizzle/meta/0008_snapshot.json
Normal file
1237
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
258
src/main/engine/ModelCatalogEngine.ts
Normal file
258
src/main/engine/ModelCatalogEngine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,17 +1190,46 @@ 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')}
|
||||||
>
|
>
|
||||||
<select
|
<div className="setting-input-group">
|
||||||
id="ai-model"
|
<select
|
||||||
value={selectedModel}
|
id="ai-model"
|
||||||
onChange={(e) => handleModelChange(e.target.value)}
|
value={selectedModel}
|
||||||
disabled={!aiHasApiKey}
|
onChange={(e) => handleModelChange(e.target.value)}
|
||||||
>
|
disabled={!aiHasApiKey}
|
||||||
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
>
|
||||||
{availableModels.map(model => (
|
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
{availableModels.map(model => (
|
||||||
))}
|
<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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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é.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
276
tests/engine/ModelCatalogEngine.test.ts
Normal file
276
tests/engine/ModelCatalogEngine.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user