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,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 { MediaEngine, type MediaData } from './MediaEngine';
import type { PostMediaEngine } from './PostMediaEngine';
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine';
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
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',
};
// Uppercase prefixes that should not be title-cased
const UPPERCASE_PREFIXES = ['gpt', 'glm'];
@@ -172,6 +175,7 @@ export class OpenCodeManager {
private cachedModels: ModelInfo[] | null = null;
private cachedModelsAt: number = 0;
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private modelCatalogEngine = new ModelCatalogEngine();
private conversationUsage: Map<string, {
inputTokens: number;
outputTokens: number;
@@ -477,7 +481,7 @@ export class OpenCodeManager {
const body: Record<string, unknown> = {
model: modelId,
max_tokens: 4096,
max_tokens: await this.getMaxOutputTokens(modelId),
system: systemPrompt,
messages,
tools,
@@ -793,7 +797,7 @@ export class OpenCodeManager {
const body: Record<string, unknown> = {
model: modelId,
max_tokens: 4096,
max_tokens: await this.getMaxOutputTokens(modelId),
messages,
tools: openaiTools,
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 {
const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic';