fix: better models.dev support
This commit is contained in:
53
drizzle/0009_model_catalog_v2.sql
Normal file
53
drizzle/0009_model_catalog_v2.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
CREATE TABLE `ai_models` (
|
||||||
|
`provider` text NOT NULL,
|
||||||
|
`model_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`family` text,
|
||||||
|
`attachment` integer DEFAULT false,
|
||||||
|
`reasoning` integer DEFAULT false,
|
||||||
|
`tool_call` integer DEFAULT false,
|
||||||
|
`structured_output` integer DEFAULT false,
|
||||||
|
`temperature` integer DEFAULT false,
|
||||||
|
`knowledge` text,
|
||||||
|
`release_date` text,
|
||||||
|
`last_updated_date` text,
|
||||||
|
`open_weights` integer DEFAULT false,
|
||||||
|
`input_price` real,
|
||||||
|
`output_price` real,
|
||||||
|
`cache_read_price` real,
|
||||||
|
`cache_write_price` real,
|
||||||
|
`context_window` integer,
|
||||||
|
`max_input_tokens` integer,
|
||||||
|
`max_output_tokens` integer,
|
||||||
|
`interleaved` text,
|
||||||
|
`status` text,
|
||||||
|
`provider_npm` text,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`provider`, `model_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `ai_catalog_meta` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `ai_model_modalities` (
|
||||||
|
`provider` text NOT NULL,
|
||||||
|
`model_id` text NOT NULL,
|
||||||
|
`direction` text NOT NULL,
|
||||||
|
`modality` text NOT NULL,
|
||||||
|
PRIMARY KEY(`provider`, `model_id`, `direction`, `modality`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `ai_providers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`env` text,
|
||||||
|
`npm` text,
|
||||||
|
`api` text,
|
||||||
|
`doc` text,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `model_catalog`;--> statement-breakpoint
|
||||||
|
DROP TABLE `model_catalog_meta`;
|
||||||
1432
drizzle/meta/0009_snapshot.json
Normal file
1432
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1772369331600,
|
"when": 1772369331600,
|
||||||
"tag": "0008_third_cable",
|
"tag": "0008_third_cable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772380619098,
|
||||||
|
"tag": "0009_model_catalog_v2",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer, real, uniqueIndex, primaryKey } 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,27 +206,64 @@ 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
|
// ── Model Catalog ──
|
||||||
// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider.
|
// Normalised tables from models.dev API.
|
||||||
// Refreshed on user action via conditional GET (ETag). Survives offline use.
|
// 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')
|
// Provider table — one row per models.dev top-level provider
|
||||||
name: text('name').notNull(), // display name
|
export const modelCatalogProviders = sqliteTable('ai_providers', {
|
||||||
family: text('family'), // model family (e.g. 'claude-sonnet')
|
id: text('id').primaryKey(), // provider key (e.g. 'opencode', 'mistral')
|
||||||
contextWindow: integer('context_window'), // max context tokens
|
name: text('name').notNull(), // display name (e.g. 'OpenCode Zen')
|
||||||
maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context)
|
env: text('env'), // JSON array of env var names
|
||||||
maxOutputTokens: integer('max_output_tokens'), // max output tokens
|
npm: text('npm'), // primary npm package
|
||||||
inputPrice: real('input_price'), // cost per 1M input tokens (USD)
|
api: text('api'), // API base URL
|
||||||
outputPrice: real('output_price'), // cost per 1M output tokens (USD)
|
doc: text('doc'), // documentation URL
|
||||||
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(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Model catalog HTTP cache metadata (ETag for conditional GET)
|
// Model table — one row per (provider, modelId) pair
|
||||||
export const modelCatalogMeta = sqliteTable('model_catalog_meta', {
|
export const modelCatalog = sqliteTable('ai_models', {
|
||||||
|
provider: text('provider').notNull(), // FK → ai_providers.id
|
||||||
|
modelId: text('model_id').notNull(),
|
||||||
|
name: text('name').notNull(), // display name (e.g. 'Claude Sonnet 4.5')
|
||||||
|
family: text('family'), // model family (e.g. 'claude-sonnet')
|
||||||
|
attachment: integer('attachment', { mode: 'boolean' }).default(false),
|
||||||
|
reasoning: integer('reasoning', { mode: 'boolean' }).default(false),
|
||||||
|
toolCall: integer('tool_call', { mode: 'boolean' }).default(false),
|
||||||
|
structuredOutput: integer('structured_output', { mode: 'boolean' }).default(false),
|
||||||
|
temperature: integer('temperature', { mode: 'boolean' }).default(false),
|
||||||
|
knowledge: text('knowledge'), // knowledge cutoff (e.g. '2025-03-31')
|
||||||
|
releaseDate: text('release_date'),
|
||||||
|
lastUpdatedDate: text('last_updated_date'),
|
||||||
|
openWeights: integer('open_weights', { mode: 'boolean' }).default(false),
|
||||||
|
inputPrice: real('input_price'), // USD per 1M input tokens
|
||||||
|
outputPrice: real('output_price'), // USD per 1M output tokens
|
||||||
|
cacheReadPrice: real('cache_read_price'),
|
||||||
|
cacheWritePrice: real('cache_write_price'),
|
||||||
|
contextWindow: integer('context_window'), // max context tokens
|
||||||
|
maxInputTokens: integer('max_input_tokens'),
|
||||||
|
maxOutputTokens: integer('max_output_tokens'),
|
||||||
|
interleaved: text('interleaved'), // JSON object (e.g. '{"field":"reasoning_content"}')
|
||||||
|
status: text('status'), // e.g. 'deprecated'
|
||||||
|
providerNpm: text('provider_npm'), // per-model npm override
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.provider, table.modelId] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Modality junction table — each row is one (direction, modality) tag for a model
|
||||||
|
// e.g. ('opencode', 'claude-sonnet-4', 'input', 'image')
|
||||||
|
export const modelCatalogModalities = sqliteTable('ai_model_modalities', {
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
modelId: text('model_id').notNull(),
|
||||||
|
direction: text('direction').notNull(), // 'input' | 'output'
|
||||||
|
modality: text('modality').notNull(), // 'text' | 'image' | 'pdf' | 'audio' | 'video'
|
||||||
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.provider, table.modelId, table.direction, table.modality] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// HTTP cache metadata (ETag for conditional GET)
|
||||||
|
export const modelCatalogMeta = sqliteTable('ai_catalog_meta', {
|
||||||
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
|
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
|
||||||
value: text('value').notNull(),
|
value: text('value').notNull(),
|
||||||
});
|
});
|
||||||
@@ -260,7 +297,11 @@ 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 ModelCatalogProviderEntry = typeof modelCatalogProviders.$inferSelect;
|
||||||
|
export type NewModelCatalogProviderEntry = typeof modelCatalogProviders.$inferInsert;
|
||||||
export type ModelCatalogEntry = typeof modelCatalog.$inferSelect;
|
export type ModelCatalogEntry = typeof modelCatalog.$inferSelect;
|
||||||
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
|
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
|
||||||
|
export type ModelCatalogModalityEntry = typeof modelCatalogModalities.$inferSelect;
|
||||||
|
export type NewModelCatalogModalityEntry = typeof modelCatalogModalities.$inferInsert;
|
||||||
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
|
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
|
||||||
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
|
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
|
||||||
|
|||||||
@@ -1,41 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
|
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
|
||||||
*
|
*
|
||||||
* Provides model output token limits, pricing info, and capabilities
|
* The full catalog is stored in three normalised SQLite tables:
|
||||||
* for all models available through the OpenCode Zen gateway.
|
* model_catalog_providers — one row per provider (opencode, mistral, …)
|
||||||
|
* model_catalog — one row per (provider, modelId) pair
|
||||||
|
* model_catalog_modalities — junction table with (provider, modelId, direction, modality) tags
|
||||||
*
|
*
|
||||||
* Data is persisted in SQLite (model_catalog + model_catalog_meta tables)
|
* Data is refreshed on user action via conditional GET (ETag).
|
||||||
* and refreshed on user action via conditional GET (ETag).
|
|
||||||
* Works fully offline after first successful fetch.
|
* Works fully offline after first successful fetch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { modelCatalog, modelCatalogMeta } from '../database/schema';
|
import { modelCatalog, modelCatalogMeta, modelCatalogProviders, modelCatalogModalities } from '../database/schema';
|
||||||
import type { ModelCatalogEntry } from '../database/schema';
|
import type { ModelCatalogEntry } from '../database/schema';
|
||||||
|
|
||||||
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
||||||
const PROVIDER_KEY = 'opencode';
|
|
||||||
|
|
||||||
// Default max output tokens when no catalog data is available
|
// Default max output tokens when no catalog data is available
|
||||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
export const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
||||||
|
|
||||||
|
/** Provider-level metadata from models.dev. */
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
env: string[];
|
||||||
|
npm: string | null;
|
||||||
|
api: string | null;
|
||||||
|
doc: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattened model info returned by query methods. */
|
||||||
export interface ModelCatalogInfo {
|
export interface ModelCatalogInfo {
|
||||||
|
provider: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
family: string | null;
|
family: string | null;
|
||||||
contextWindow: number | null;
|
attachment: boolean;
|
||||||
maxInputTokens: number | null;
|
reasoning: boolean;
|
||||||
maxOutputTokens: number | null;
|
toolCall: boolean;
|
||||||
|
structuredOutput: boolean;
|
||||||
|
temperature: boolean;
|
||||||
|
knowledge: string | null;
|
||||||
|
releaseDate: string | null;
|
||||||
|
lastUpdatedDate: string | null;
|
||||||
|
openWeights: boolean;
|
||||||
inputPrice: number | null;
|
inputPrice: number | null;
|
||||||
outputPrice: number | null;
|
outputPrice: number | null;
|
||||||
cacheReadPrice: number | null;
|
cacheReadPrice: number | null;
|
||||||
supportsAttachments: boolean | null;
|
cacheWritePrice: number | null;
|
||||||
supportsReasoning: boolean | null;
|
contextWindow: number | null;
|
||||||
supportsToolCall: boolean | null;
|
maxInputTokens: number | null;
|
||||||
|
maxOutputTokens: number | null;
|
||||||
|
interleaved: string | null;
|
||||||
|
status: string | null;
|
||||||
|
providerNpm: string | null;
|
||||||
|
inputModalities: string[];
|
||||||
|
outputModalities: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshResult {
|
export interface RefreshResult {
|
||||||
@@ -58,30 +82,87 @@ export class ModelCatalogEngine {
|
|||||||
async getAll(): Promise<ModelCatalogInfo[]> {
|
async getAll(): Promise<ModelCatalogInfo[]> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const rows = await db.select().from(modelCatalog);
|
const rows = await db.select().from(modelCatalog);
|
||||||
return rows.map(toInfo);
|
const modalities = await db.select().from(modelCatalogModalities);
|
||||||
|
return rows.map(r => toInfo(r, modalities));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single model's catalog entry by ID.
|
* Get all models for a specific provider.
|
||||||
*/
|
*/
|
||||||
async getModel(modelId: string): Promise<ModelCatalogInfo | null> {
|
async getByProvider(provider: string): Promise<ModelCatalogInfo[]> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId));
|
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.provider, provider));
|
||||||
return rows.length > 0 ? toInfo(rows[0]) : null;
|
const modalities = await db.select().from(modelCatalogModalities).where(eq(modelCatalogModalities.provider, provider));
|
||||||
|
return rows.map(r => toInfo(r, modalities));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single model by provider and model ID.
|
||||||
|
*/
|
||||||
|
async getModel(modelId: string, provider?: string): Promise<ModelCatalogInfo | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
let rows: ModelCatalogEntry[];
|
||||||
|
if (provider) {
|
||||||
|
rows = await db.select().from(modelCatalog).where(
|
||||||
|
and(eq(modelCatalog.provider, provider), eq(modelCatalog.modelId, modelId)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Search across all providers, return first match
|
||||||
|
rows = await db.select().from(modelCatalog).where(eq(modelCatalog.modelId, modelId));
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const row = rows[0];
|
||||||
|
const modalities = await db.select().from(modelCatalogModalities).where(
|
||||||
|
and(eq(modelCatalogModalities.provider, row.provider), eq(modelCatalogModalities.modelId, row.modelId)),
|
||||||
|
);
|
||||||
|
return toInfo(row, modalities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all providers from the catalog.
|
||||||
|
*/
|
||||||
|
async getProviders(): Promise<ProviderInfo[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const rows = await db.select().from(modelCatalogProviders);
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
env: r.env ? JSON.parse(r.env) as string[] : [],
|
||||||
|
npm: r.npm,
|
||||||
|
api: r.api,
|
||||||
|
doc: r.doc,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the max output tokens for a model (used by OpenCodeManager for max_tokens).
|
* 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.
|
* Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog.
|
||||||
*/
|
*/
|
||||||
async getMaxOutputTokens(modelId: string): Promise<number> {
|
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
|
||||||
const model = await this.getModel(modelId);
|
const model = await this.getModel(modelId, provider);
|
||||||
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context window size for a model.
|
||||||
|
* Returns null if the model is not in the catalog.
|
||||||
|
*/
|
||||||
|
async getContextWindow(modelId: string, provider?: string): Promise<number | null> {
|
||||||
|
const model = await this.getModel(modelId, provider);
|
||||||
|
return model?.contextWindow ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a model supports a specific input modality (e.g. 'image').
|
||||||
|
*/
|
||||||
|
async hasInputModality(modelId: string, modality: string, provider?: string): Promise<boolean> {
|
||||||
|
const model = await this.getModel(modelId, provider);
|
||||||
|
return model?.inputModalities.includes(modality) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the model catalog from models.dev using conditional GET (ETag).
|
* Refresh the model catalog from models.dev using conditional GET (ETag).
|
||||||
* Returns the number of models updated, or notModified if the data hasn't changed.
|
* Stores ALL providers and ALL models from the API.
|
||||||
*/
|
*/
|
||||||
async refresh(): Promise<RefreshResult> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
try {
|
try {
|
||||||
@@ -109,9 +190,16 @@ export class ModelCatalogEngine {
|
|||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
const models = data?.[PROVIDER_KEY]?.models;
|
if (!data || typeof data !== 'object') {
|
||||||
if (!models || typeof models !== 'object') {
|
return { success: false, modelsUpdated: 0, error: 'Invalid response: not an object' };
|
||||||
return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' };
|
}
|
||||||
|
|
||||||
|
// Count providers with models
|
||||||
|
const providerEntries = Object.entries(data).filter(
|
||||||
|
([, v]) => v && typeof v === 'object' && 'models' in (v as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
if (providerEntries.length === 0) {
|
||||||
|
return { success: false, modelsUpdated: 0, error: 'Invalid response: no providers found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store new ETag
|
// Store new ETag
|
||||||
@@ -121,10 +209,18 @@ export class ModelCatalogEngine {
|
|||||||
}
|
}
|
||||||
await this.setMeta('lastFetchedAt', new Date().toISOString());
|
await this.setMeta('lastFetchedAt', new Date().toISOString());
|
||||||
|
|
||||||
// Upsert all models
|
// Upsert all providers and their models
|
||||||
const count = await this.upsertModels(models);
|
let totalModels = 0;
|
||||||
|
for (const [providerId, providerData] of providerEntries) {
|
||||||
|
const prov = providerData as Record<string, unknown>;
|
||||||
|
await this.upsertProvider(providerId, prov);
|
||||||
|
const models = prov.models as Record<string, unknown> | undefined;
|
||||||
|
if (models && typeof models === 'object') {
|
||||||
|
totalModels += await this.upsertModels(providerId, models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, modelsUpdated: count };
|
return { success: true, modelsUpdated: totalModels };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, modelsUpdated: 0, error: (error as Error).message };
|
return { success: false, modelsUpdated: 0, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
@@ -140,10 +236,42 @@ export class ModelCatalogEngine {
|
|||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse models.dev model entries and upsert into database.
|
* Upsert a provider row.
|
||||||
|
*/
|
||||||
|
private async upsertProvider(id: string, data: Record<string, unknown>): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const now = new Date();
|
||||||
|
const env = Array.isArray(data.env) ? JSON.stringify(data.env) : null;
|
||||||
|
|
||||||
|
await db.insert(modelCatalogProviders)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: (data.name as string) || id,
|
||||||
|
env,
|
||||||
|
npm: (data.npm as string) || null,
|
||||||
|
api: (data.api as string) || null,
|
||||||
|
doc: (data.doc as string) || null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: modelCatalogProviders.id,
|
||||||
|
set: {
|
||||||
|
name: (data.name as string) || id,
|
||||||
|
env,
|
||||||
|
npm: (data.npm as string) || null,
|
||||||
|
api: (data.api as string) || null,
|
||||||
|
doc: (data.doc as string) || null,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and upsert model entries for a given provider.
|
||||||
|
* Also writes modality rows to the junction table.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private async upsertModels(models: Record<string, any>): Promise<number> {
|
private async upsertModels(providerId: string, models: Record<string, any>): Promise<number> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -152,40 +280,87 @@ export class ModelCatalogEngine {
|
|||||||
if (!info || typeof info !== 'object') continue;
|
if (!info || typeof info !== 'object') continue;
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
id,
|
provider: providerId,
|
||||||
|
modelId: id,
|
||||||
name: info.name || id,
|
name: info.name || id,
|
||||||
family: info.family || null,
|
family: info.family || null,
|
||||||
contextWindow: info.limit?.context ?? null,
|
attachment: info.attachment ?? false,
|
||||||
maxInputTokens: info.limit?.input ?? null,
|
reasoning: info.reasoning ?? false,
|
||||||
maxOutputTokens: info.limit?.output ?? null,
|
toolCall: info.tool_call ?? false,
|
||||||
|
structuredOutput: info.structured_output ?? false,
|
||||||
|
temperature: info.temperature ?? false,
|
||||||
|
knowledge: info.knowledge || null,
|
||||||
|
releaseDate: info.release_date || null,
|
||||||
|
lastUpdatedDate: info.last_updated || null,
|
||||||
|
openWeights: info.open_weights ?? false,
|
||||||
inputPrice: info.cost?.input ?? null,
|
inputPrice: info.cost?.input ?? null,
|
||||||
outputPrice: info.cost?.output ?? null,
|
outputPrice: info.cost?.output ?? null,
|
||||||
cacheReadPrice: info.cost?.cache_read ?? null,
|
cacheReadPrice: info.cost?.cache_read ?? null,
|
||||||
supportsAttachments: info.attachment ?? false,
|
cacheWritePrice: info.cost?.cache_write ?? null,
|
||||||
supportsReasoning: info.reasoning ?? false,
|
contextWindow: info.limit?.context ?? null,
|
||||||
supportsToolCall: info.tool_call ?? false,
|
maxInputTokens: info.limit?.input ?? null,
|
||||||
|
maxOutputTokens: info.limit?.output ?? null,
|
||||||
|
interleaved: info.interleaved ? JSON.stringify(info.interleaved) : null,
|
||||||
|
status: info.status || null,
|
||||||
|
providerNpm: info.provider?.npm || null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(modelCatalog)
|
await db.insert(modelCatalog)
|
||||||
.values(entry)
|
.values(entry)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: modelCatalog.id,
|
target: [modelCatalog.provider, modelCatalog.modelId],
|
||||||
set: {
|
set: {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
family: entry.family,
|
family: entry.family,
|
||||||
contextWindow: entry.contextWindow,
|
attachment: entry.attachment,
|
||||||
maxInputTokens: entry.maxInputTokens,
|
reasoning: entry.reasoning,
|
||||||
maxOutputTokens: entry.maxOutputTokens,
|
toolCall: entry.toolCall,
|
||||||
|
structuredOutput: entry.structuredOutput,
|
||||||
|
temperature: entry.temperature,
|
||||||
|
knowledge: entry.knowledge,
|
||||||
|
releaseDate: entry.releaseDate,
|
||||||
|
lastUpdatedDate: entry.lastUpdatedDate,
|
||||||
|
openWeights: entry.openWeights,
|
||||||
inputPrice: entry.inputPrice,
|
inputPrice: entry.inputPrice,
|
||||||
outputPrice: entry.outputPrice,
|
outputPrice: entry.outputPrice,
|
||||||
cacheReadPrice: entry.cacheReadPrice,
|
cacheReadPrice: entry.cacheReadPrice,
|
||||||
supportsAttachments: entry.supportsAttachments,
|
cacheWritePrice: entry.cacheWritePrice,
|
||||||
supportsReasoning: entry.supportsReasoning,
|
contextWindow: entry.contextWindow,
|
||||||
supportsToolCall: entry.supportsToolCall,
|
maxInputTokens: entry.maxInputTokens,
|
||||||
|
maxOutputTokens: entry.maxOutputTokens,
|
||||||
|
interleaved: entry.interleaved,
|
||||||
|
status: entry.status,
|
||||||
|
providerNpm: entry.providerNpm,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upsert modality tags
|
||||||
|
const mods = info.modalities;
|
||||||
|
if (mods && typeof mods === 'object') {
|
||||||
|
for (const direction of ['input', 'output'] as const) {
|
||||||
|
const tags = mods[direction];
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
for (const modality of tags) {
|
||||||
|
if (typeof modality === 'string') {
|
||||||
|
await db.insert(modelCatalogModalities)
|
||||||
|
.values({ provider: providerId, modelId: id, direction, modality })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
modelCatalogModalities.provider,
|
||||||
|
modelCatalogModalities.modelId,
|
||||||
|
modelCatalogModalities.direction,
|
||||||
|
modelCatalogModalities.modality,
|
||||||
|
],
|
||||||
|
set: { modality }, // no-op update to satisfy ON CONFLICT
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,19 +415,38 @@ export class ModelCatalogEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toInfo(row: ModelCatalogEntry): ModelCatalogInfo {
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/** Map of (provider, modelId) → { input: string[], output: string[] } for modalities */
|
||||||
|
type ModalityEntry = { provider: string; modelId: string; direction: string; modality: string };
|
||||||
|
|
||||||
|
function toInfo(row: ModelCatalogEntry, allModalities: ModalityEntry[]): ModelCatalogInfo {
|
||||||
|
const rowModalities = allModalities.filter(m => m.provider === row.provider && m.modelId === row.modelId);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
provider: row.provider,
|
||||||
|
id: row.modelId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
family: row.family,
|
family: row.family,
|
||||||
contextWindow: row.contextWindow,
|
attachment: row.attachment ?? false,
|
||||||
maxInputTokens: row.maxInputTokens,
|
reasoning: row.reasoning ?? false,
|
||||||
maxOutputTokens: row.maxOutputTokens,
|
toolCall: row.toolCall ?? false,
|
||||||
|
structuredOutput: row.structuredOutput ?? false,
|
||||||
|
temperature: row.temperature ?? false,
|
||||||
|
knowledge: row.knowledge,
|
||||||
|
releaseDate: row.releaseDate,
|
||||||
|
lastUpdatedDate: row.lastUpdatedDate,
|
||||||
|
openWeights: row.openWeights ?? false,
|
||||||
inputPrice: row.inputPrice,
|
inputPrice: row.inputPrice,
|
||||||
outputPrice: row.outputPrice,
|
outputPrice: row.outputPrice,
|
||||||
cacheReadPrice: row.cacheReadPrice,
|
cacheReadPrice: row.cacheReadPrice,
|
||||||
supportsAttachments: row.supportsAttachments,
|
cacheWritePrice: row.cacheWritePrice ?? null,
|
||||||
supportsReasoning: row.supportsReasoning,
|
contextWindow: row.contextWindow,
|
||||||
supportsToolCall: row.supportsToolCall,
|
maxInputTokens: row.maxInputTokens,
|
||||||
|
maxOutputTokens: row.maxOutputTokens,
|
||||||
|
interleaved: row.interleaved,
|
||||||
|
status: row.status,
|
||||||
|
providerNpm: row.providerNpm,
|
||||||
|
inputModalities: rowModalities.filter(m => m.direction === 'input').map(m => m.modality),
|
||||||
|
outputModalities: rowModalities.filter(m => m.direction === 'output').map(m => m.modality),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,99 +38,8 @@ const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
|||||||
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
||||||
const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
|
const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
|
||||||
|
|
||||||
// Known model display names: maps model IDs to polished names and serves as offline fallback
|
|
||||||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
|
||||||
// Anthropic Claude
|
|
||||||
'claude-opus-4-6': 'Claude Opus 4.6',
|
|
||||||
'claude-opus-4-5': 'Claude Opus 4.5',
|
|
||||||
'claude-opus-4-1': 'Claude Opus 4.1',
|
|
||||||
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
|
|
||||||
'claude-sonnet-4-5': 'Claude Sonnet 4.5',
|
|
||||||
'claude-sonnet-4': 'Claude Sonnet 4',
|
|
||||||
'claude-haiku-4-5': 'Claude Haiku 4.5',
|
|
||||||
'claude-3-5-haiku': 'Claude 3.5 Haiku',
|
|
||||||
// OpenAI GPT
|
|
||||||
'gpt-5.3-codex': 'GPT 5.3 Codex',
|
|
||||||
'gpt-5.2': 'GPT 5.2',
|
|
||||||
'gpt-5.2-codex': 'GPT 5.2 Codex',
|
|
||||||
'gpt-5.1': 'GPT 5.1',
|
|
||||||
'gpt-5.1-codex': 'GPT 5.1 Codex',
|
|
||||||
'gpt-5.1-codex-max': 'GPT 5.1 Codex Max',
|
|
||||||
'gpt-5.1-codex-mini': 'GPT 5.1 Codex Mini',
|
|
||||||
'gpt-5': 'GPT 5',
|
|
||||||
'gpt-5-codex': 'GPT 5 Codex',
|
|
||||||
'gpt-5-nano': 'GPT 5 Nano',
|
|
||||||
// Google Gemini
|
|
||||||
'gemini-3.1-pro': 'Gemini 3.1 Pro',
|
|
||||||
'gemini-3-pro': 'Gemini 3 Pro',
|
|
||||||
'gemini-3-flash': 'Gemini 3 Flash',
|
|
||||||
// Other providers
|
|
||||||
'glm-5': 'GLM 5',
|
|
||||||
'glm-5-free': 'GLM 5 Free',
|
|
||||||
'glm-4.7': 'GLM 4.7',
|
|
||||||
'glm-4.6': 'GLM 4.6',
|
|
||||||
'qwen3-coder': 'Qwen3 Coder',
|
|
||||||
'minimax-m2.5': 'MiniMax M2.5',
|
|
||||||
'minimax-m2.5-free': 'MiniMax M2.5 Free',
|
|
||||||
'minimax-m2.1': 'MiniMax M2.1',
|
|
||||||
'minimax-m2.1-free': 'MiniMax M2.1 Free',
|
|
||||||
'kimi-k2.5': 'Kimi K2.5',
|
|
||||||
'kimi-k2.5-free': 'Kimi K2.5 Free',
|
|
||||||
'kimi-k2': 'Kimi K2',
|
|
||||||
'kimi-k2-thinking': 'Kimi K2 Thinking',
|
|
||||||
'big-pickle': 'Big Pickle',
|
|
||||||
'trinity-large-preview-free': 'Trinity Large Preview Free',
|
|
||||||
// Mistral AI
|
|
||||||
'mistral-large-latest': 'Mistral Large',
|
|
||||||
'mistral-medium-latest': 'Mistral Medium',
|
|
||||||
'mistral-small-latest': 'Mistral Small',
|
|
||||||
'devstral-small-latest': 'Devstral Small',
|
|
||||||
'devstral-large-latest': 'Devstral Large',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Uppercase prefixes that should not be title-cased
|
|
||||||
const UPPERCASE_PREFIXES = ['gpt', 'glm'];
|
|
||||||
|
|
||||||
// Per-model context token budgets for truncation
|
|
||||||
// OpenCode models default to 150,000; Mistral models have specific budgets
|
|
||||||
const MODEL_CONTEXT_BUDGETS: Record<string, number> = {
|
|
||||||
'mistral-large-latest': 35_000,
|
|
||||||
'mistral-medium-latest': 35_000,
|
|
||||||
'mistral-small-latest': 120_000,
|
|
||||||
'devstral-small-latest': 120_000,
|
|
||||||
'devstral-large-latest': 240_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vision capabilities per model (APIs don't expose this)
|
|
||||||
const MODEL_CAPABILITIES: Record<string, { vision: boolean }> = {
|
|
||||||
// Anthropic Claude — all vision-capable
|
|
||||||
'claude-opus-4-6': { vision: true },
|
|
||||||
'claude-opus-4-5': { vision: true },
|
|
||||||
'claude-opus-4-1': { vision: true },
|
|
||||||
'claude-sonnet-4-6': { vision: true },
|
|
||||||
'claude-sonnet-4-5': { vision: true },
|
|
||||||
'claude-sonnet-4': { vision: true },
|
|
||||||
'claude-haiku-4-5': { vision: true },
|
|
||||||
'claude-3-5-haiku': { vision: true },
|
|
||||||
// OpenAI GPT — most are vision-capable
|
|
||||||
'gpt-5': { vision: true },
|
|
||||||
'gpt-5.1': { vision: true },
|
|
||||||
'gpt-5.2': { vision: true },
|
|
||||||
'gpt-5-nano': { vision: true },
|
|
||||||
// Google Gemini — vision-capable
|
|
||||||
'gemini-3.1-pro': { vision: true },
|
|
||||||
'gemini-3-pro': { vision: true },
|
|
||||||
'gemini-3-flash': { vision: true },
|
|
||||||
// Mistral AI
|
|
||||||
'mistral-large-latest': { vision: true },
|
|
||||||
'mistral-medium-latest': { vision: true },
|
|
||||||
'mistral-small-latest': { vision: true },
|
|
||||||
'devstral-small-latest': { vision: false },
|
|
||||||
'devstral-large-latest': { vision: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
metadata?: {
|
metadata?: {
|
||||||
surface?: 'tab' | 'sidebar';
|
surface?: 'tab' | 'sidebar';
|
||||||
@@ -303,6 +212,8 @@ export class OpenCodeManager {
|
|||||||
{ 'x-api-key': apiKey },
|
{ 'x-api-key': apiKey },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||||
|
|
||||||
for (const headers of attempts) {
|
for (const headers of attempts) {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
||||||
@@ -310,10 +221,15 @@ export class OpenCodeManager {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
// Filter to only OpenCode models (not Mistral)
|
const data = JSON.parse(response.body);
|
||||||
const models = Object.entries(MODEL_DISPLAY_NAMES)
|
const models = (data.data && Array.isArray(data.data))
|
||||||
.map(([id, name]) => ({ id, name, provider: this.detectProvider(id), vision: MODEL_CAPABILITIES[id]?.vision ?? false }))
|
? (data.data as Array<{ id: string }>).map(m => ({
|
||||||
.filter(m => this.isProviderKeySet(m.provider));
|
id: m.id,
|
||||||
|
name: this.resolveName(m.id, catalogNames),
|
||||||
|
provider: this.detectProvider(m.id),
|
||||||
|
vision: this.resolveVision(m.id, catalogVision),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
return { isValid: true, models };
|
return { isValid: true, models };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -332,6 +248,8 @@ export class OpenCodeManager {
|
|||||||
return { isValid: false, models: [] };
|
return { isValid: false, models: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
|
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -343,10 +261,14 @@ export class OpenCodeManager {
|
|||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
if (data.data && Array.isArray(data.data) && data.data.length > 0) {
|
if (data.data && Array.isArray(data.data) && data.data.length > 0) {
|
||||||
// Return Mistral models from display name map
|
const models = (data.data as Array<{ id: string }>)
|
||||||
const models = Object.entries(MODEL_DISPLAY_NAMES)
|
.filter(m => this.detectProvider(m.id) === 'mistral')
|
||||||
.filter(([id]) => this.detectProvider(id) === 'mistral')
|
.map(m => ({
|
||||||
.map(([id, name]) => ({ id, name, provider: 'mistral', vision: MODEL_CAPABILITIES[id]?.vision ?? false }));
|
id: m.id,
|
||||||
|
name: this.resolveName(m.id, catalogNames),
|
||||||
|
provider: 'mistral',
|
||||||
|
vision: this.resolveVision(m.id, catalogVision),
|
||||||
|
}));
|
||||||
return { isValid: true, models };
|
return { isValid: true, models };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +292,9 @@ export class OpenCodeManager {
|
|||||||
const allModels: ChatModel[] = [];
|
const allModels: ChatModel[] = [];
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
|
|
||||||
|
// Load catalog for vision + name cross-referencing
|
||||||
|
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||||
|
|
||||||
// Fetch OpenCode models
|
// Fetch OpenCode models
|
||||||
if (this.apiKey) {
|
if (this.apiKey) {
|
||||||
try {
|
try {
|
||||||
@@ -386,9 +311,9 @@ export class OpenCodeManager {
|
|||||||
for (const m of data.data as Array<{ id: string }>) {
|
for (const m of data.data as Array<{ id: string }>) {
|
||||||
allModels.push({
|
allModels.push({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: this.formatModelName(m.id),
|
name: this.resolveName(m.id, catalogNames),
|
||||||
provider: this.detectProvider(m.id),
|
provider: this.detectProvider(m.id),
|
||||||
vision: MODEL_CAPABILITIES[m.id]?.vision ?? false,
|
vision: this.resolveVision(m.id, catalogVision),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
fetched = true;
|
fetched = true;
|
||||||
@@ -412,13 +337,12 @@ export class OpenCodeManager {
|
|||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
if (data.data && Array.isArray(data.data)) {
|
if (data.data && Array.isArray(data.data)) {
|
||||||
for (const m of data.data as Array<{ id: string }>) {
|
for (const m of data.data as Array<{ id: string }>) {
|
||||||
// Only include models we know about (have display names)
|
if (this.detectProvider(m.id) === 'mistral') {
|
||||||
if (MODEL_DISPLAY_NAMES[m.id]) {
|
|
||||||
allModels.push({
|
allModels.push({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: this.formatModelName(m.id),
|
name: this.resolveName(m.id, catalogNames),
|
||||||
provider: 'mistral',
|
provider: 'mistral',
|
||||||
vision: MODEL_CAPABILITIES[m.id]?.vision ?? false,
|
vision: this.resolveVision(m.id, catalogVision),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,16 +360,23 @@ export class OpenCodeManager {
|
|||||||
return allModels;
|
return allModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fallback from display name map, filtered by available provider keys
|
// Fallback: build from model catalog database (models.dev), filtered by available provider keys
|
||||||
const fallback = Object.entries(MODEL_DISPLAY_NAMES)
|
try {
|
||||||
.map(([id, name]) => ({
|
const catalog = await this.modelCatalogEngine.getAll();
|
||||||
id,
|
if (catalog.length > 0) {
|
||||||
name,
|
return catalog
|
||||||
provider: this.detectProvider(id),
|
.map(m => ({
|
||||||
vision: MODEL_CAPABILITIES[id]?.vision ?? false,
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
provider: this.detectProvider(m.id),
|
||||||
|
vision: m.inputModalities.includes('image'),
|
||||||
}))
|
}))
|
||||||
.filter(m => this.isProviderKeySet(m.provider));
|
.filter(m => this.isProviderKeySet(m.provider));
|
||||||
return fallback;
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to empty
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -943,7 +874,7 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
// Truncate conversation history to fit within context window
|
// Truncate conversation history to fit within context window
|
||||||
// Keep system message (index 0), truncate from oldest conversation messages
|
// Keep system message (index 0), truncate from oldest conversation messages
|
||||||
const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000;
|
const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000;
|
||||||
const conversationMessages = allMessages.slice(1);
|
const conversationMessages = allMessages.slice(1);
|
||||||
const anthropicFmt = conversationMessages.map(m => ({
|
const anthropicFmt = conversationMessages.map(m => ({
|
||||||
role: m.role as 'user' | 'assistant',
|
role: m.role as 'user' | 'assistant',
|
||||||
@@ -2245,6 +2176,40 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
|
|||||||
return !!this.apiKey;
|
return !!this.apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load model catalog into maps for quick vision and name lookups.
|
||||||
|
* Vision = model has 'image' in its input modalities.
|
||||||
|
*/
|
||||||
|
private async getCatalogLookups(): Promise<{ vision: Map<string, boolean>; names: Map<string, string> }> {
|
||||||
|
const vision = new Map<string, boolean>();
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
const catalog = await this.modelCatalogEngine.getAll();
|
||||||
|
for (const m of catalog) {
|
||||||
|
vision.set(m.id, m.inputModalities.includes('image'));
|
||||||
|
names.set(m.id, m.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Catalog unavailable — maps stay empty
|
||||||
|
}
|
||||||
|
return { vision, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve vision capability for a model ID.
|
||||||
|
* Vision = 'image' is in the model's input modalities from the catalog.
|
||||||
|
*/
|
||||||
|
private resolveVision(modelId: string, catalogVision: Map<string, boolean>): boolean {
|
||||||
|
return catalogVision.get(modelId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve display name for a model ID. Falls back to raw model ID.
|
||||||
|
*/
|
||||||
|
private resolveName(modelId: string, catalogNames: Map<string, string>): string {
|
||||||
|
return catalogNames.get(modelId) ?? modelId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return API URL, key and provider-specific options for a given provider.
|
* Return API URL, key and provider-specific options for a given provider.
|
||||||
* Used to parameterise sendOpenAIMessage() for non-Anthropic providers.
|
* Used to parameterise sendOpenAIMessage() for non-Anthropic providers.
|
||||||
@@ -2265,24 +2230,7 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
|
|||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatModelName(modelId: string): string {
|
|
||||||
// Check display name map first
|
|
||||||
if (MODEL_DISPLAY_NAMES[modelId]) {
|
|
||||||
return MODEL_DISPLAY_NAMES[modelId];
|
|
||||||
}
|
|
||||||
// Auto-format: split on hyphens, handle uppercase prefixes and version dots
|
|
||||||
const words = modelId.split('-');
|
|
||||||
return words
|
|
||||||
.map((word, index) => {
|
|
||||||
// First word: check for uppercase prefixes
|
|
||||||
if (index === 0 && UPPERCASE_PREFIXES.includes(word.toLowerCase())) {
|
|
||||||
return word.toUpperCase();
|
|
||||||
}
|
|
||||||
// Capitalize first letter
|
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseErrorResponse(response: HttpResponse): string {
|
private parseErrorResponse(response: HttpResponse): string {
|
||||||
let errorMsg = `API error: ${response.statusCode}`;
|
let errorMsg = `API error: ${response.statusCode}`;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* ModelCatalogEngine Tests
|
* ModelCatalogEngine Tests
|
||||||
*
|
*
|
||||||
* Tests the model catalog engine that fetches and caches
|
* Tests the model catalog engine that fetches and caches
|
||||||
* model metadata from models.dev for the OpenCode provider.
|
* model metadata from models.dev for ALL providers.
|
||||||
|
* Three normalised tables: providers → models → modalities.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
@@ -19,12 +20,37 @@ function createSelectChain(mockData: unknown[] = []) {
|
|||||||
return chain;
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectMockData: unknown[] = [];
|
// Per-table mock data keyed by table name reference
|
||||||
|
let modelMockData: unknown[] = [];
|
||||||
|
let modalityMockData: unknown[] = [];
|
||||||
|
let providerMockData: unknown[] = [];
|
||||||
|
let metaMockData: unknown[] = [];
|
||||||
const insertedValues: unknown[] = [];
|
const insertedValues: unknown[] = [];
|
||||||
|
|
||||||
function createDrizzleMock() {
|
function createDrizzleMock() {
|
||||||
return {
|
return {
|
||||||
select: vi.fn(() => createSelectChain(selectMockData)),
|
select: vi.fn(() => {
|
||||||
|
// Returns a chain whose `.from()` picks the right dataset by table reference
|
||||||
|
const chain: Record<string, unknown> = {
|
||||||
|
from: vi.fn().mockImplementation((table: unknown) => {
|
||||||
|
let data: unknown[];
|
||||||
|
if (table === modelCatalogModalities) {
|
||||||
|
data = modalityMockData;
|
||||||
|
} else if (table === modelCatalogProviders) {
|
||||||
|
data = providerMockData;
|
||||||
|
} else if (table === modelCatalogMeta) {
|
||||||
|
data = metaMockData;
|
||||||
|
} else {
|
||||||
|
data = modelMockData;
|
||||||
|
}
|
||||||
|
const inner = createSelectChain(data);
|
||||||
|
return inner;
|
||||||
|
}),
|
||||||
|
where: vi.fn().mockImplementation(() => chain),
|
||||||
|
then: (resolve: (v: unknown) => void) => Promise.resolve(modelMockData).then(resolve),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
insert: vi.fn(() => ({
|
insert: vi.fn(() => ({
|
||||||
values: vi.fn((data: unknown) => {
|
values: vi.fn((data: unknown) => {
|
||||||
insertedValues.push(data);
|
insertedValues.push(data);
|
||||||
@@ -49,13 +75,19 @@ vi.mock('../../src/main/database', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
|
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
|
||||||
|
import { modelCatalog, modelCatalogModalities, modelCatalogProviders, modelCatalogMeta } from '../../src/main/database/schema';
|
||||||
|
|
||||||
// ── Sample models.dev response ──
|
// ── Sample models.dev response (multi-provider) ──
|
||||||
|
|
||||||
function sampleModelsDevResponse() {
|
function sampleModelsDevResponse() {
|
||||||
return {
|
return {
|
||||||
opencode: {
|
opencode: {
|
||||||
id: 'opencode',
|
id: 'opencode',
|
||||||
|
name: 'OpenCode Zen',
|
||||||
|
env: ['OPENCODE_API_KEY'],
|
||||||
|
npm: '@ai-sdk/openai-compatible',
|
||||||
|
api: 'https://opencode.ai/zen/v1',
|
||||||
|
doc: 'https://opencode.ai/docs/zen',
|
||||||
models: {
|
models: {
|
||||||
'claude-sonnet-4-5': {
|
'claude-sonnet-4-5': {
|
||||||
id: 'claude-sonnet-4-5',
|
id: 'claude-sonnet-4-5',
|
||||||
@@ -64,6 +96,7 @@ function sampleModelsDevResponse() {
|
|||||||
attachment: true,
|
attachment: true,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
tool_call: true,
|
tool_call: true,
|
||||||
|
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||||
cost: { input: 3, output: 15, cache_read: 0.3 },
|
cost: { input: 3, output: 15, cache_read: 0.3 },
|
||||||
limit: { context: 200000, output: 64000 },
|
limit: { context: 200000, output: 64000 },
|
||||||
},
|
},
|
||||||
@@ -74,6 +107,7 @@ function sampleModelsDevResponse() {
|
|||||||
attachment: true,
|
attachment: true,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
tool_call: true,
|
tool_call: true,
|
||||||
|
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||||
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
|
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
|
||||||
limit: { context: 400000, input: 272000, output: 128000 },
|
limit: { context: 400000, input: 272000, output: 128000 },
|
||||||
},
|
},
|
||||||
@@ -81,10 +115,32 @@ function sampleModelsDevResponse() {
|
|||||||
id: 'model-no-cost',
|
id: 'model-no-cost',
|
||||||
name: 'Free Model',
|
name: 'Free Model',
|
||||||
family: 'free',
|
family: 'free',
|
||||||
|
modalities: { input: ['text'], output: ['text'] },
|
||||||
limit: { context: 32000, output: 4096 },
|
limit: { context: 32000, output: 4096 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mistral: {
|
||||||
|
id: 'mistral',
|
||||||
|
name: 'Mistral AI',
|
||||||
|
env: ['MISTRAL_API_KEY'],
|
||||||
|
npm: '@mistralai/mistralai',
|
||||||
|
api: 'https://api.mistral.ai/v1',
|
||||||
|
doc: 'https://docs.mistral.ai',
|
||||||
|
models: {
|
||||||
|
'mistral-large-latest': {
|
||||||
|
id: 'mistral-large-latest',
|
||||||
|
name: 'Mistral Large',
|
||||||
|
family: 'mistral',
|
||||||
|
attachment: true,
|
||||||
|
reasoning: false,
|
||||||
|
tool_call: true,
|
||||||
|
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||||
|
cost: { input: 2, output: 6 },
|
||||||
|
limit: { context: 128000, output: 8192 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,53 +149,75 @@ describe('ModelCatalogEngine', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
|
providerMockData = [];
|
||||||
|
metaMockData = [];
|
||||||
insertedValues.length = 0;
|
insertedValues.length = 0;
|
||||||
engine = new ModelCatalogEngine();
|
engine = new ModelCatalogEngine();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('returns all cached model catalog entries', async () => {
|
it('returns all cached model catalog entries with modalities', async () => {
|
||||||
selectMockData = [
|
modelMockData = [
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
|
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||||
|
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
modalityMockData = [
|
||||||
|
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'text' },
|
||||||
|
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
|
||||||
|
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'output', modality: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
const result = await engine.getAll();
|
const result = await engine.getAll();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe('claude-sonnet-4-5');
|
expect(result[0].id).toBe('claude-sonnet-4-5');
|
||||||
|
expect(result[0].provider).toBe('opencode');
|
||||||
expect(result[0].maxOutputTokens).toBe(64000);
|
expect(result[0].maxOutputTokens).toBe(64000);
|
||||||
expect(result[0].inputPrice).toBe(3);
|
expect(result[0].inputPrice).toBe(3);
|
||||||
|
expect(result[0].inputModalities).toEqual(['text', 'image']);
|
||||||
|
expect(result[0].outputModalities).toEqual(['text']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array when no catalog entries exist', async () => {
|
it('returns empty array when no catalog entries exist', async () => {
|
||||||
selectMockData = [];
|
|
||||||
const result = await engine.getAll();
|
const result = await engine.getAll();
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getModel', () => {
|
describe('getModel', () => {
|
||||||
it('returns a specific model by ID', async () => {
|
it('returns a specific model by ID (cross-provider search)', async () => {
|
||||||
selectMockData = [{
|
modelMockData = [{
|
||||||
id: 'gpt-5', name: 'GPT 5', family: 'gpt',
|
provider: 'opencode', modelId: 'gpt-5', name: 'GPT 5', family: 'gpt',
|
||||||
|
attachment: true, reasoning: true, toolCall: true,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
|
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
|
||||||
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107,
|
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: true, supportsToolCall: true,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
}];
|
}];
|
||||||
|
modalityMockData = [
|
||||||
|
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'text' },
|
||||||
|
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'image' },
|
||||||
|
];
|
||||||
|
|
||||||
const result = await engine.getModel('gpt-5');
|
const result = await engine.getModel('gpt-5');
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.name).toBe('GPT 5');
|
expect(result!.name).toBe('GPT 5');
|
||||||
expect(result!.maxOutputTokens).toBe(128000);
|
expect(result!.maxOutputTokens).toBe(128000);
|
||||||
|
expect(result!.inputModalities).toEqual(['text', 'image']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for unknown model', async () => {
|
it('returns null for unknown model', async () => {
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
const result = await engine.getModel('nonexistent');
|
const result = await engine.getModel('nonexistent');
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -147,38 +225,92 @@ describe('ModelCatalogEngine', () => {
|
|||||||
|
|
||||||
describe('getMaxOutputTokens', () => {
|
describe('getMaxOutputTokens', () => {
|
||||||
it('returns output tokens from catalog when available', async () => {
|
it('returns output tokens from catalog when available', async () => {
|
||||||
selectMockData = [{
|
modelMockData = [{
|
||||||
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
|
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||||
|
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
}];
|
}];
|
||||||
|
modalityMockData = [];
|
||||||
|
|
||||||
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
|
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
|
||||||
expect(result).toBe(64000);
|
expect(result).toBe(64000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
|
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
const result = await engine.getMaxOutputTokens('unknown-model');
|
const result = await engine.getMaxOutputTokens('unknown-model');
|
||||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
|
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
|
||||||
selectMockData = [{
|
modelMockData = [{
|
||||||
id: 'weird-model', name: 'Weird', family: null,
|
provider: 'opencode', modelId: 'weird-model', name: 'Weird', family: null,
|
||||||
|
attachment: false, reasoning: false, toolCall: false,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
|
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
|
||||||
inputPrice: null, outputPrice: null, cacheReadPrice: null,
|
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
|
||||||
supportsAttachments: false, supportsReasoning: false, supportsToolCall: false,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
}];
|
}];
|
||||||
|
modalityMockData = [];
|
||||||
|
|
||||||
const result = await engine.getMaxOutputTokens('weird-model');
|
const result = await engine.getMaxOutputTokens('weird-model');
|
||||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasInputModality', () => {
|
||||||
|
it('returns true when model has the modality', async () => {
|
||||||
|
modelMockData = [{
|
||||||
|
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
|
||||||
|
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
|
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||||
|
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||||
|
interleaved: null, status: null, providerNpm: null,
|
||||||
|
}];
|
||||||
|
modalityMockData = [
|
||||||
|
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await engine.hasInputModality('claude-sonnet-4-5', 'image');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when model lacks the modality', async () => {
|
||||||
|
modelMockData = [{
|
||||||
|
provider: 'opencode', modelId: 'text-only', name: 'Text Only',
|
||||||
|
family: null, attachment: false, reasoning: false, toolCall: false,
|
||||||
|
structuredOutput: false, temperature: false, knowledge: null,
|
||||||
|
releaseDate: null, lastUpdatedDate: null, openWeights: false,
|
||||||
|
contextWindow: 32000, maxInputTokens: null, maxOutputTokens: 4096,
|
||||||
|
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
|
||||||
|
interleaved: null, status: null, providerNpm: null,
|
||||||
|
}];
|
||||||
|
modalityMockData = [
|
||||||
|
{ provider: 'opencode', modelId: 'text-only', direction: 'input', modality: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await engine.hasInputModality('text-only', 'image');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for unknown model', async () => {
|
||||||
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
|
const result = await engine.hasInputModality('nonexistent', 'image');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('refresh', () => {
|
describe('refresh', () => {
|
||||||
it('parses models.dev response and inserts models into DB', async () => {
|
it('parses multi-provider models.dev response and inserts all providers and models', async () => {
|
||||||
const mockResponse = sampleModelsDevResponse();
|
const mockResponse = sampleModelsDevResponse();
|
||||||
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -186,13 +318,16 @@ describe('ModelCatalogEngine', () => {
|
|||||||
headers: { etag: '"abc123"' },
|
headers: { etag: '"abc123"' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// getMeta returns null (no existing etag)
|
metaMockData = [];
|
||||||
selectMockData = [];
|
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.modelsUpdated).toBe(3);
|
// 3 opencode models + 1 mistral model = 4
|
||||||
|
expect(result.modelsUpdated).toBe(4);
|
||||||
expect(result.notModified).toBeUndefined();
|
expect(result.notModified).toBeUndefined();
|
||||||
|
|
||||||
|
// Should have inserted provider rows and model rows and modality rows
|
||||||
|
expect(insertedValues.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends If-None-Match header when ETag is cached', async () => {
|
it('sends If-None-Match header when ETag is cached', async () => {
|
||||||
@@ -208,7 +343,9 @@ describe('ModelCatalogEngine', () => {
|
|||||||
mockLocalDb.select = vi.fn(() => {
|
mockLocalDb.select = vi.fn(() => {
|
||||||
metaCallCount++;
|
metaCallCount++;
|
||||||
if (metaCallCount === 1) {
|
if (metaCallCount === 1) {
|
||||||
return createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
|
// getMeta('etag') → picks up model_catalog_meta table
|
||||||
|
const chain = createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
|
||||||
|
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(chain), ...chain }), ...chain };
|
||||||
}
|
}
|
||||||
return createSelectChain([]);
|
return createSelectChain([]);
|
||||||
}) as any;
|
}) as any;
|
||||||
@@ -231,7 +368,7 @@ describe('ModelCatalogEngine', () => {
|
|||||||
body: 'Internal Server Error',
|
body: 'Internal Server Error',
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -240,24 +377,24 @@ describe('ModelCatalogEngine', () => {
|
|||||||
|
|
||||||
it('handles network errors gracefully', async () => {
|
it('handles network errors gracefully', async () => {
|
||||||
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
|
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBe('ECONNREFUSED');
|
expect(result.error).toBe('ECONNREFUSED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invalid response (missing opencode provider)', async () => {
|
it('handles invalid response (no providers)', async () => {
|
||||||
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: JSON.stringify({ other_provider: { models: {} } }),
|
body: JSON.stringify({}),
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('no opencode models');
|
expect(result.error).toContain('no providers');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles malformed JSON gracefully', async () => {
|
it('handles malformed JSON gracefully', async () => {
|
||||||
@@ -266,7 +403,7 @@ describe('ModelCatalogEngine', () => {
|
|||||||
body: 'not valid json {{{',
|
body: 'not valid json {{{',
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
* - getAvailableModels() merge from both providers
|
* - getAvailableModels() merge from both providers
|
||||||
* - getProviderConfig() helper
|
* - getProviderConfig() helper
|
||||||
* - isProviderKeySet() helper
|
* - isProviderKeySet() helper
|
||||||
* - MODEL_CONTEXT_BUDGETS correctness
|
* - Vision from catalog modalities
|
||||||
* - MODEL_CAPABILITIES (vision flags)
|
|
||||||
* - validateMistralApiKey()
|
* - validateMistralApiKey()
|
||||||
* - Provider-aware routing in sendOpenAIMessage()
|
* - Provider-aware routing in sendOpenAIMessage()
|
||||||
* - generateConversationTitle() provider routing
|
* - generateConversationTitle() provider routing
|
||||||
@@ -336,10 +335,20 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
expect(providers.has('mistral')).toBe(true);
|
expect(providers.has('mistral')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes vision field on models', async () => {
|
it('includes vision field from catalog modalities', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.setMistralApiKey('mist-key');
|
manager.setMistralApiKey('mist-key');
|
||||||
|
|
||||||
|
// Mock catalog with modality data for vision resolution
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||||
if (url.includes('mistral.ai')) {
|
if (url.includes('mistral.ai')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -367,6 +376,14 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
// No OpenCode key set
|
// No OpenCode key set
|
||||||
|
|
||||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
// Should only have Mistral models from fallback
|
// Should only have Mistral models from fallback
|
||||||
@@ -409,19 +426,6 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MODEL_DISPLAY_NAMES includes Mistral models', () => {
|
|
||||||
it('has display names for all target Mistral models', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('mistral-large-latest')).toBe('Mistral Large');
|
|
||||||
expect(format('mistral-medium-latest')).toBe('Mistral Medium');
|
|
||||||
expect(format('mistral-small-latest')).toBe('Mistral Small');
|
|
||||||
expect(format('devstral-small-latest')).toBe('Devstral Small');
|
|
||||||
expect(format('devstral-large-latest')).toBe('Devstral Large');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateConversationTitle provider routing', () => {
|
describe('generateConversationTitle provider routing', () => {
|
||||||
it('uses Mistral API when conversation model is a Mistral model', async () => {
|
it('uses Mistral API when conversation model is a Mistral model', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
@@ -529,39 +533,24 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MODEL_CONTEXT_BUDGETS', () => {
|
describe('vision from catalog modalities', () => {
|
||||||
it('has correct budget values for all Mistral models', () => {
|
it('vision flags are derived from catalog input modalities via getAvailableModels', async () => {
|
||||||
// Access the constant via a model that triggers truncation path
|
|
||||||
const manager = createManager();
|
|
||||||
// We verify the budgets via the getProviderConfig indirectly,
|
|
||||||
// but here we check them via the module-level constant accessed via the manager
|
|
||||||
// by using sendOpenAIMessage truncation behavior.
|
|
||||||
// Since the budgets map is not exported, we test the values are correct
|
|
||||||
// by checking the truncation call parameter via a mock.
|
|
||||||
|
|
||||||
// Access budgets through internal reference
|
|
||||||
const budgets: Record<string, number> = {
|
|
||||||
'mistral-large-latest': 35_000,
|
|
||||||
'mistral-medium-latest': 35_000,
|
|
||||||
'mistral-small-latest': 120_000,
|
|
||||||
'devstral-small-latest': 120_000,
|
|
||||||
'devstral-large-latest': 240_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify each budget is reasonable (within expected ranges)
|
|
||||||
expect(budgets['mistral-large-latest']).toBe(35_000);
|
|
||||||
expect(budgets['mistral-medium-latest']).toBe(35_000);
|
|
||||||
expect(budgets['mistral-small-latest']).toBe(120_000);
|
|
||||||
expect(budgets['devstral-small-latest']).toBe(120_000);
|
|
||||||
expect(budgets['devstral-large-latest']).toBe(240_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MODEL_CAPABILITIES', () => {
|
|
||||||
it('vision flags are correct for Mistral models via getAvailableModels', async () => {
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.setMistralApiKey('mist-key');
|
manager.setMistralApiKey('mist-key');
|
||||||
|
|
||||||
|
// Mock catalog with modality data
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'mistral-medium-latest', name: 'Mistral Medium', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'mistral-small-latest', name: 'Mistral Small', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||||
|
{ id: 'devstral-large-latest', name: 'Devstral Large', inputModalities: ['text'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||||
if (url.includes('mistral.ai')) {
|
if (url.includes('mistral.ai')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -580,12 +569,12 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
|
|
||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
// Vision-capable models
|
// Vision-capable models (image in input modalities)
|
||||||
expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true);
|
expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true);
|
||||||
expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true);
|
expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true);
|
||||||
expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true);
|
expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true);
|
||||||
|
|
||||||
// Non-vision models
|
// Non-vision models (no image in input modalities)
|
||||||
expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false);
|
expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false);
|
||||||
expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false);
|
expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -670,10 +659,9 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateApiKey model filtering', () => {
|
describe('validateApiKey returns models from API response', () => {
|
||||||
it('filters out models whose provider key is not set', async () => {
|
it('returns models from the actual API response', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
// Only OpenCode key — no Mistral key
|
|
||||||
manager.setApiKey('oc-key');
|
manager.setApiKey('oc-key');
|
||||||
|
|
||||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||||
@@ -683,9 +671,9 @@ describe('OpenCodeManager Mistral integration', () => {
|
|||||||
|
|
||||||
const result = await manager.validateApiKey('oc-key');
|
const result = await manager.validateApiKey('oc-key');
|
||||||
expect(result.isValid).toBe(true);
|
expect(result.isValid).toBe(true);
|
||||||
// Should NOT include Mistral models
|
expect(result.models).toHaveLength(1);
|
||||||
const mistralModels = result.models.filter(m => m.provider === 'mistral');
|
expect(result.models[0].id).toBe('claude-sonnet-4');
|
||||||
expect(mistralModels.length).toBe(0);
|
expect(result.models[0].provider).toBe('anthropic');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,76 +67,21 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatModelName', () => {
|
|
||||||
it('formats Claude model IDs with proper spacing', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('claude-opus-4-6')).toBe('Claude Opus 4.6');
|
|
||||||
expect(format('claude-sonnet-4-5')).toBe('Claude Sonnet 4.5');
|
|
||||||
expect(format('claude-sonnet-4')).toBe('Claude Sonnet 4');
|
|
||||||
expect(format('claude-haiku-4-5')).toBe('Claude Haiku 4.5');
|
|
||||||
expect(format('claude-3-5-haiku')).toBe('Claude 3.5 Haiku');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats GPT model IDs with uppercase prefix', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('gpt-5')).toBe('GPT 5');
|
|
||||||
expect(format('gpt-5.1')).toBe('GPT 5.1');
|
|
||||||
expect(format('gpt-5.1-codex')).toBe('GPT 5.1 Codex');
|
|
||||||
expect(format('gpt-5.1-codex-max')).toBe('GPT 5.1 Codex Max');
|
|
||||||
expect(format('gpt-5.1-codex-mini')).toBe('GPT 5.1 Codex Mini');
|
|
||||||
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
|
|
||||||
expect(format('gpt-5.3-codex')).toBe('GPT 5.3 Codex');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats GLM model IDs with uppercase prefix', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('glm-5')).toBe('GLM 5');
|
|
||||||
expect(format('glm-4.7')).toBe('GLM 4.7');
|
|
||||||
expect(format('glm-4.6')).toBe('GLM 4.6');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats Gemini model IDs properly', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('gemini-3-pro')).toBe('Gemini 3 Pro');
|
|
||||||
expect(format('gemini-3-flash')).toBe('Gemini 3 Flash');
|
|
||||||
expect(format('gemini-3.1-pro')).toBe('Gemini 3.1 Pro');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats free/preview suffixes', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
|
|
||||||
expect(format('minimax-m2.5-free')).toBe('MiniMax M2.5 Free');
|
|
||||||
expect(format('kimi-k2.5-free')).toBe('Kimi K2.5 Free');
|
|
||||||
expect(format('trinity-large-preview-free')).toBe('Trinity Large Preview Free');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats other provider model IDs', () => {
|
|
||||||
const manager = createManager();
|
|
||||||
const format = (manager as any).formatModelName.bind(manager);
|
|
||||||
|
|
||||||
expect(format('minimax-m2.5')).toBe('MiniMax M2.5');
|
|
||||||
expect(format('minimax-m2.1')).toBe('MiniMax M2.1');
|
|
||||||
expect(format('kimi-k2.5')).toBe('Kimi K2.5');
|
|
||||||
expect(format('kimi-k2')).toBe('Kimi K2');
|
|
||||||
expect(format('kimi-k2-thinking')).toBe('Kimi K2 Thinking');
|
|
||||||
expect(format('qwen3-coder')).toBe('Qwen3 Coder');
|
|
||||||
expect(format('big-pickle')).toBe('Big Pickle');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAvailableModels', () => {
|
describe('getAvailableModels', () => {
|
||||||
it('returns models from API with proper names and providers', async () => {
|
it('returns models from API with catalog names and catalog-derived vision', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
|
// Mock catalog with modality data and display names
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] },
|
||||||
|
{ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] },
|
||||||
|
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const zenResponse = createZenModelResponse([
|
const zenResponse = createZenModelResponse([
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
'gpt-5.1-codex',
|
'gpt-5.1-codex',
|
||||||
@@ -156,30 +101,45 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
|
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to known models when API fails', async () => {
|
it('falls back to model catalog when API fails', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models.length).toBeGreaterThan(0);
|
expect(models.length).toBeGreaterThan(0);
|
||||||
// Should include well-known models from the display name map
|
|
||||||
const ids = models.map((m: ChatModel) => m.id);
|
const ids = models.map((m: ChatModel) => m.id);
|
||||||
expect(ids).toContain('claude-sonnet-4');
|
expect(ids).toContain('claude-sonnet-4');
|
||||||
expect(ids).toContain('gpt-5');
|
expect(ids).toContain('gpt-5');
|
||||||
// Every model should have proper provider detection
|
|
||||||
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
|
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
|
||||||
expect(claudeModel?.provider).toBe('anthropic');
|
expect(claudeModel?.provider).toBe('anthropic');
|
||||||
|
expect(claudeModel?.name).toBe('Claude Sonnet 4');
|
||||||
const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5');
|
const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5');
|
||||||
expect(gptModel?.provider).toBe('openai');
|
expect(gptModel?.provider).toBe('openai');
|
||||||
|
expect(gptModel?.name).toBe('GPT 5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back when API returns non-200 status', async () => {
|
it('falls back to model catalog when API returns non-200 status', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: '{"error":"unauthorized"}',
|
body: '{"error":"unauthorized"}',
|
||||||
});
|
});
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
@@ -220,7 +180,7 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
expect(httpRequest).toHaveBeenCalledTimes(2);
|
expect(httpRequest).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles unknown model IDs from API with auto-formatting', async () => {
|
it('handles unknown model IDs from API with raw IDs as fallback names', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
||||||
|
|
||||||
@@ -232,15 +192,22 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models).toHaveLength(1);
|
expect(models).toHaveLength(1);
|
||||||
expect(models[0].name).toBe('Some New Model V3');
|
expect(models[0].name).toBe('some-new-model-v3');
|
||||||
expect(models[0].provider).toBe('other');
|
expect(models[0].provider).toBe('other');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to known models when no API key is set', async () => {
|
it('falls back to model catalog when no API key is set', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
(manager as any).apiKey = '';
|
(manager as any).apiKey = '';
|
||||||
// Set a key so fallback filtering works (at least one provider must have a key)
|
|
||||||
manager.setMistralApiKey('test-key');
|
manager.setMistralApiKey('test-key');
|
||||||
|
(manager as any).modelCatalogEngine = {
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||||
|
]),
|
||||||
|
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||||
|
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
@@ -248,6 +215,8 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
expect(models.length).toBeGreaterThan(0);
|
expect(models.length).toBeGreaterThan(0);
|
||||||
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||||
expect(providers.has('mistral')).toBe(true);
|
expect(providers.has('mistral')).toBe(true);
|
||||||
|
// OpenCode/Anthropic models should be filtered out (no OpenCode key)
|
||||||
|
expect(providers.has('anthropic')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user