From c7a883d18d7078bd8a5c38a88f74c7e760affbf2 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 9 Mar 2026 17:27:57 +0100 Subject: [PATCH 1/2] feat(agents): add GPT-5.3-Codex, Claude Haiku 4.5, and Kimi K2.5 models with Responses API routing - Add gpt-5.3-codex-2026-02-24, claude-haiku-4-5-20251001, and moonshotai/kimi-k2.5 to model configs - Split model registry into chat-completions vs responses-api groups (use_responses_api flag) - Route Responses API models through OpenCode's built-in openai CUSTOM_LOADER (sdk.responses()) - Fix SessionStore to fall back to completed_{sessionId}.json on SSO sync race condition - Add node:sqlite native driver as primary SQLite query strategy (Node.js 22.5+) - Add node-sqlite.d.ts type declarations for compile-time safety - Update test mocks: replace getAllOpenCodeModelConfigs with getChatCompletionsModelConfigs/getResponsesApiModelConfigs Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/session/SessionStore.ts | 19 ++- .../__tests__/codemie-code-plugin.test.ts | 5 +- .../__tests__/codemie-code-reasoning.test.ts | 5 +- src/agents/plugins/codemie-code.plugin.ts | 49 ++++++-- src/agents/plugins/opencode/node-sqlite.d.ts | 24 ++++ .../opencode/opencode-model-configs.ts | 115 ++++++++++++++++-- .../plugins/opencode/opencode.plugin.ts | 36 +++++- .../opencode/opencode.sqlite-reader.ts | 53 +++++++- 8 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 src/agents/plugins/opencode/node-sqlite.d.ts diff --git a/src/agents/core/session/SessionStore.ts b/src/agents/core/session/SessionStore.ts index 716344ac..b1e8390f 100644 --- a/src/agents/core/session/SessionStore.ts +++ b/src/agents/core/session/SessionStore.ts @@ -7,7 +7,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; -import { dirname } from 'path'; +import { dirname, basename, join } from 'path'; import type { Session } from './types.js'; import { getSessionPath } from './session-config.js'; import { logger } from '../../../utils/logger.js'; @@ -41,13 +41,24 @@ export class SessionStore { /** * Load session from disk + * + * Falls back to the 'completed_' prefixed filename when the primary path is + * not found. This handles the race where handleSessionEnd renames + * {sessionId}.json → completed_{sessionId}.json before the final SSO sync runs. */ async loadSession(sessionId: string): Promise { - const sessionPath = getSessionPath(sessionId); + let sessionPath = getSessionPath(sessionId); if (!existsSync(sessionPath)) { - logger.debug(`[SessionStore] Session file not found: ${sessionId}`); - return null; + // Fallback: session may have been renamed with 'completed_' prefix by handleSessionEnd + const completedPath = join(dirname(sessionPath), `completed_${basename(sessionPath)}`); + if (existsSync(completedPath)) { + sessionPath = completedPath; + logger.debug(`[SessionStore] Using completed session file: ${sessionId}`); + } else { + logger.debug(`[SessionStore] Session file not found: ${sessionId}`); + return null; + } } try { diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts index b58b8db4..9e02a976 100644 --- a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -84,7 +84,7 @@ vi.mock('../opencode/opencode.session.js', () => ({ }), })); -// Mock getModelConfig and getAllOpenCodeModelConfigs +// Mock getModelConfig and model config helpers vi.mock('../opencode/opencode-model-configs.js', () => ({ getModelConfig: vi.fn(() => ({ id: 'gpt-5-2-2025-12-11', @@ -102,7 +102,8 @@ vi.mock('../opencode/opencode-model-configs.js', () => ({ cost: { input: 2.5, output: 10 }, limit: { context: 1048576, output: 65536 }, })), - getAllOpenCodeModelConfigs: vi.fn(() => ({})), + getChatCompletionsModelConfigs: vi.fn(() => ({})), + getResponsesApiModelConfigs: vi.fn(() => ({})), })); // Mock fs diff --git a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts index 08540bea..9b489369 100644 --- a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts @@ -89,7 +89,7 @@ vi.mock('../opencode/opencode.session.js', () => ({ }), })); -// Mock getModelConfig and getAllOpenCodeModelConfigs +// Mock getModelConfig and model config helpers vi.mock('../opencode/opencode-model-configs.js', () => ({ getModelConfig: vi.fn(() => ({ id: 'gpt-5-2-2025-12-11', @@ -98,7 +98,8 @@ vi.mock('../opencode/opencode-model-configs.js', () => ({ tool_call: true, reasoning: true, })), - getAllOpenCodeModelConfigs: vi.fn(() => ({})), + getChatCompletionsModelConfigs: vi.fn(() => ({})), + getResponsesApiModelConfigs: vi.fn(() => ({})), })); // Mock fs diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index 4e68771b..3bd00ab0 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -2,7 +2,7 @@ import type { AgentMetadata, AgentConfig } from '../core/types.js'; import { join } from 'path'; import { existsSync } from 'fs'; import { logger } from '../../utils/logger.js'; -import { getModelConfig, getAllOpenCodeModelConfigs } from './opencode/opencode-model-configs.js'; +import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode/opencode-model-configs.js'; import { BaseAgentAdapter } from '../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../core/extension/BaseExtensionInstaller.js'; @@ -73,6 +73,10 @@ function resolveOllamaBaseUrl(baseUrl: string, provider: string | undefined): st /** * Build the OpenCode config object that gets passed to the whitelabel binary. + * + * Models are split into two groups: + * - chatModels: routed via codemie-proxy/litellm (Chat Completions API) + * - responsesApiModels: routed via OpenCode's built-in openai CUSTOM_LOADER (Responses API) */ function buildOpenCodeConfig(params: { proxyBaseUrl: string | undefined; @@ -83,10 +87,13 @@ function buildOpenCodeConfig(params: { modelId: string; timeout: number; providerOptions?: any; - allModels: Record; + chatModels: Record; + responsesApiModels: Record; + responsesApiBaseUrl: string | undefined; }): Record { + const hasResponsesApiModels = Object.keys(params.responsesApiModels).length > 0; return { - enabled_providers: ['codemie-proxy', 'ollama', 'amazon-bedrock', 'litellm'], + enabled_providers: ['codemie-proxy', 'openai', 'ollama', 'amazon-bedrock', 'litellm'], share: 'disabled', provider: { ...(params.proxyBaseUrl && { @@ -99,7 +106,24 @@ function buildOpenCodeConfig(params: { timeout: params.timeout, ...(params.providerOptions?.headers && { headers: params.providerOptions.headers }) }, - models: params.allModels + models: params.chatModels + } + }), + // OpenCode's built-in openai CUSTOM_LOADER — uses @ai-sdk/openai sdk.responses() + // which calls POST /v1/responses instead of /v1/chat/completions + ...(params.responsesApiBaseUrl && hasResponsesApiModels && { + openai: { + name: 'CodeMie SSO', + // whitelist: suppress the built-in openai model list (GPT-4, GPT-4o, etc.) + // OpenCode merges user models with models.dev — whitelist restricts to ours only + whitelist: Object.keys(params.responsesApiModels), + options: { + baseURL: `${params.responsesApiBaseUrl}/`, + apiKey: 'proxy-handled', + timeout: params.timeout, + ...(params.providerOptions?.headers && { headers: params.providerOptions.headers }) + }, + models: params.responsesApiModels } }), ...(params.litellmBaseUrl && { @@ -111,7 +135,7 @@ function buildOpenCodeConfig(params: { apiKey: params.litellmApiKey || 'not-required', timeout: params.timeout, }, - models: params.allModels + models: params.chatModels } }), ollama: { @@ -229,7 +253,8 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; const modelConfig = getModelConfig(selectedModel); const { providerOptions } = modelConfig; - const allModels = getAllOpenCodeModelConfigs(); + const chatModels = getChatCompletionsModelConfigs(); + const responsesApiModels = getResponsesApiModelConfigs(); const isBedrock = provider === 'bedrock'; const isLiteLLM = provider === 'litellm'; @@ -241,11 +266,21 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id; + // Responses API base URL: use proxyBaseUrl for SSO/bearer-auth, or baseUrl for LiteLLM. + // Always set regardless of selected model — fixes model-switching bug where switching + // from a Claude model to a GPT model mid-session would miss the CUSTOM_LOADER. + const responsesApiBaseUrl = proxyBaseUrl || (isLiteLLM ? baseUrl : undefined); + if (responsesApiBaseUrl && Object.keys(responsesApiModels).length > 0) { + env.OPENAI_API_KEY = 'proxy-handled'; + logger.debug('[codemie-code] Enabling openai CUSTOM_LOADER for Responses API models'); + } + const openCodeConfig = buildOpenCodeConfig({ proxyBaseUrl, litellmBaseUrl: isLiteLLM ? baseUrl : undefined, litellmApiKey: isLiteLLM ? env.CODEMIE_API_KEY : undefined, - ollamaBaseUrl, activeProvider, modelId, timeout, providerOptions, allModels + ollamaBaseUrl, activeProvider, modelId, timeout, providerOptions, + chatModels, responsesApiModels, responsesApiBaseUrl }); // --- Hooks injection --- diff --git a/src/agents/plugins/opencode/node-sqlite.d.ts b/src/agents/plugins/opencode/node-sqlite.d.ts new file mode 100644 index 00000000..057eed51 --- /dev/null +++ b/src/agents/plugins/opencode/node-sqlite.d.ts @@ -0,0 +1,24 @@ +/** + * Minimal type declarations for node:sqlite (Node.js 22.5+, experimental) + * + * node:sqlite lacks @types declarations in @types/node@20.x. + * These ambient declarations provide compile-time safety for the sqlite reader. + * At runtime, Node.js 22.5+ provides the module natively. + */ +declare module 'node:sqlite' { + interface StatementSync { + all(...params: unknown[]): Record[]; + } + + interface DatabaseSyncOptions { + open?: boolean; + } + + class DatabaseSync { + constructor(path: string, options?: DatabaseSyncOptions); + prepare(sql: string): StatementSync; + close(): void; + } + + export { DatabaseSync }; +} diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index 73441202..0ace76c0 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -21,6 +21,8 @@ export interface OpenCodeModelConfig { temperature: boolean; /** Structured output support (OpenCode format: structured_output) */ structured_output?: boolean; + /** Whether model requires OpenAI Responses API instead of Chat Completions API */ + use_responses_api?: boolean; /** Modality support */ modalities: { input: string[]; @@ -70,6 +72,7 @@ export const OPENCODE_MODEL_CONFIGS: Record = { release_date: '2025-12-11', last_updated: '2025-12-11', open_weights: false, + use_responses_api: true, cost: { input: 1.75, output: 14, @@ -97,6 +100,7 @@ export const OPENCODE_MODEL_CONFIGS: Record = { release_date: '2025-11-14', last_updated: '2025-11-14', open_weights: false, + use_responses_api: true, cost: { input: 1.25, output: 10, @@ -124,6 +128,7 @@ export const OPENCODE_MODEL_CONFIGS: Record = { release_date: '2025-11-14', last_updated: '2025-11-14', open_weights: false, + use_responses_api: true, cost: { input: 0.25, output: 2, @@ -152,6 +157,7 @@ export const OPENCODE_MODEL_CONFIGS: Record = { release_date: '2025-11-13', last_updated: '2025-11-13', open_weights: false, + use_responses_api: true, cost: { input: 1.25, output: 10, @@ -180,6 +186,7 @@ export const OPENCODE_MODEL_CONFIGS: Record = { release_date: '2025-12-11', last_updated: '2025-12-11', open_weights: false, + use_responses_api: true, cost: { input: 1.75, output: 14, @@ -191,6 +198,36 @@ export const OPENCODE_MODEL_CONFIGS: Record = { } }, + 'gpt-5.3-codex-2026-02-24': { + id: 'gpt-5.3-codex-2026-02-24', + name: 'GPT-5.3 Codex', + displayName: 'GPT-5.3 Codex', + family: 'gpt-5-codex', + tool_call: true, + reasoning: true, + attachment: true, + temperature: false, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-08-31', + release_date: '2026-02-24', + last_updated: '2026-02-24', + open_weights: false, + use_responses_api: true, + cost: { + input: 1.75, + output: 14, + cache_read: 0.175 + }, + limit: { + context: 272000, + output: 128000 + } + }, + // ── Claude Models ────────────────────────────────────────────────── 'claude-4-5-sonnet': { id: 'claude-4-5-sonnet', @@ -305,14 +342,15 @@ export const OPENCODE_MODEL_CONFIGS: Record = { } }, 'claude-haiku-4-5': { - id: 'claude-haiku-4-5', + id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', displayName: 'Claude Haiku 4.5', family: 'claude-4', tool_call: true, - reasoning: false, + reasoning: true, attachment: true, temperature: true, + structured_output: true, modalities: { input: ['text', 'image'], output: ['text'] @@ -322,13 +360,13 @@ export const OPENCODE_MODEL_CONFIGS: Record = { last_updated: '2025-10-01', open_weights: false, cost: { - input: 0.8, - output: 4, - cache_read: 0.08 + input: 1.10, + output: 5.50, + cache_read: 0.11 }, limit: { context: 200000, - output: 8192 + output: 64000 } }, @@ -386,6 +424,34 @@ export const OPENCODE_MODEL_CONFIGS: Record = { } }, + // ── Kimi Models (via Bedrock) ────────────────────────────────────── + 'moonshotai.kimi-k2.5': { + id: 'moonshotai.kimi-k2.5', + name: 'Kimi K2.5', + displayName: 'Kimi K2.5', + family: 'kimi', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-03-01', + release_date: '2025-10-01', + last_updated: '2025-10-01', + open_weights: true, + cost: { + input: 0.6, + output: 3.03 + }, + limit: { + context: 262144, + output: 262144 + } + }, + // ── Gemini Models ────────────────────────────────────────────────── 'gemini-2.5-pro': { id: 'gemini-2.5-pro', @@ -446,13 +512,42 @@ export const OPENCODE_MODEL_CONFIGS: Record = { }; /** - * Get all model configs stripped of CodeMie-specific fields (displayName, providerOptions). + * Get all model configs stripped of CodeMie-specific fields (displayName, providerOptions, use_responses_api). * Used to populate all models in the OpenCode config so users can switch models during a session. */ -export function getAllOpenCodeModelConfigs(): Record> { - const result: Record> = {}; +export function getAllOpenCodeModelConfigs(): Record> { + const result: Record> = {}; + for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { + const { displayName: _d, providerOptions: _p, use_responses_api: _r, ...opencodeConfig } = config; + result[id] = opencodeConfig; + } + return result; +} + +/** + * Get model configs for Chat Completions API providers (codemie-proxy, litellm). + * Excludes models that require the OpenAI Responses API. + */ +export function getChatCompletionsModelConfigs(): Record> { + const result: Record> = {}; + for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { + if (config.use_responses_api) continue; + const { displayName: _d, providerOptions: _p, use_responses_api: _r, ...opencodeConfig } = config; + result[id] = opencodeConfig; + } + return result; +} + +/** + * Get model configs that require the OpenAI Responses API. + * These are routed through OpenCode's built-in openai CUSTOM_LOADER + * which calls POST /v1/responses instead of POST /v1/chat/completions. + */ +export function getResponsesApiModelConfigs(): Record> { + const result: Record> = {}; for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { - const { displayName: _displayName, providerOptions: _providerOptions, ...opencodeConfig } = config; + if (!config.use_responses_api) continue; + const { displayName: _d, providerOptions: _p, use_responses_api: _r, ...opencodeConfig } = config; result[id] = opencodeConfig; } return result; diff --git a/src/agents/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index 96d7a298..e5ec0829 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -1,6 +1,6 @@ import type { AgentMetadata, AgentConfig } from '../../core/types.js'; import { logger } from '../../../utils/logger.js'; -import { getModelConfig, getAllOpenCodeModelConfigs } from './opencode-model-configs.js'; +import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode-model-configs.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; @@ -69,8 +69,9 @@ export const OpenCodePluginMetadata: AgentMetadata = { const { providerOptions } = modelConfig; - // Build all models for codemie-proxy (stripped of CodeMie-specific fields) - const allModels = getAllOpenCodeModelConfigs(); + // Split models by API type + const chatModels = getChatCompletionsModelConfigs(); + const responsesApiModels = getResponsesApiModelConfigs(); // Determine URLs based on provider type const isBedrock = provider === 'bedrock'; @@ -86,8 +87,17 @@ export const OpenCodePluginMetadata: AgentMetadata = { const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : 'codemie-proxy'); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; + // Always enable openai CUSTOM_LOADER when Responses API models exist. + // This fixes model-switching: if user starts with Claude and switches to GPT, + // the CUSTOM_LOADER must already be registered. + if (proxyBaseUrl && Object.keys(responsesApiModels).length > 0) { + env.OPENAI_API_KEY = 'proxy-handled'; + logger.debug('[opencode] Enabling openai CUSTOM_LOADER for Responses API models'); + } + + const hasResponsesApiModels = Object.keys(responsesApiModels).length > 0; const openCodeConfig: Record = { - enabled_providers: ['codemie-proxy', 'ollama', 'amazon-bedrock'], + enabled_providers: ['codemie-proxy', 'openai', 'ollama', 'amazon-bedrock'], share: 'disabled', provider: { ...(proxyBaseUrl && { @@ -100,7 +110,23 @@ export const OpenCodePluginMetadata: AgentMetadata = { timeout, ...(providerOptions?.headers && { headers: providerOptions.headers }) }, - models: allModels + models: chatModels + } + }), + // Built-in openai CUSTOM_LOADER: routes Responses API models via sdk.responses() + ...(proxyBaseUrl && hasResponsesApiModels && { + openai: { + name: 'CodeMie SSO', + // whitelist: suppress the built-in openai model list (GPT-4, GPT-4o, etc.) + // OpenCode merges user models with models.dev — whitelist restricts to ours only + whitelist: Object.keys(responsesApiModels), + options: { + baseURL: `${proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout, + ...(providerOptions?.headers && { headers: providerOptions.headers }) + }, + models: responsesApiModels } }), ollama: { diff --git a/src/agents/plugins/opencode/opencode.sqlite-reader.ts b/src/agents/plugins/opencode/opencode.sqlite-reader.ts index b4b17bc2..84454361 100644 --- a/src/agents/plugins/opencode/opencode.sqlite-reader.ts +++ b/src/agents/plugins/opencode/opencode.sqlite-reader.ts @@ -1,12 +1,14 @@ /** * OpenCode SQLite Reader * - * Reads session/message/part data from OpenCode's SQLite database (opencode.db) - * using the system `sqlite3` CLI with `-json` output. + * Reads session/message/part data from OpenCode's SQLite database (opencode.db). * * OpenCode migrated from file-based storage (JSON in storage/session/, storage/message/, - * storage/part/) to SQLite. This module provides read access to the new format without - * adding any npm dependencies — it shells out to the pre-installed sqlite3 CLI. + * storage/part/) to SQLite. This module provides read access to the new format. + * + * Query strategy (in priority order): + * 1. node:sqlite — Node.js built-in (22.5+, no external tools required) + * 2. sqlite3 CLI — fallback for older Node.js versions * * Usage: Called once at session end (not performance-critical). */ @@ -25,9 +27,20 @@ import type { } from './opencode-message-types.js'; /** - * Check if the sqlite3 CLI is available on this system. + * Check if SQLite reading is available on this system. + * + * Prefers node:sqlite (Node.js 22.5+, no external tools required). + * Falls back to checking for the sqlite3 CLI binary. */ export async function isSqliteAvailable(): Promise { + // Try node:sqlite first (Node.js 22.5+, no external tools required) + try { + const { DatabaseSync } = await import('node:sqlite'); + return typeof DatabaseSync === 'function'; + } catch { + // node:sqlite not available (Node.js < 22.5) + } + // Fall back to sqlite3 CLI return commandExists('sqlite3'); } @@ -46,14 +59,44 @@ export function getDbPathFromStorage(storagePath: string): string | null { return existsSync(dbPath) ? dbPath : null; } +/** + * Execute a read-only SQLite query using Node.js native node:sqlite (Node.js 22.5+). + * + * @returns Parsed rows, or null if node:sqlite is unavailable or the query fails + */ +async function queryDbViaNative(dbPath: string, sql: string): Promise { + try { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(dbPath, { open: true }); + try { + const stmt = db.prepare(sql); + return stmt.all() as unknown as T[]; + } finally { + db.close(); + } + } catch { + return null; + } +} + /** * Execute a read-only SQLite query and return parsed JSON rows. * + * Tries node:sqlite first (Node.js 22.5+, no external tools required). + * Falls back to the sqlite3 CLI if node:sqlite is unavailable. + * * @param dbPath - Path to the SQLite database * @param sql - SQL query to execute * @returns Parsed rows as an array of objects */ async function queryDb(dbPath: string, sql: string): Promise { + // Try native node:sqlite first (no external tool required) + const nativeResult = await queryDbViaNative(dbPath, sql); + if (nativeResult !== null) { + return nativeResult; + } + + // Fallback to sqlite3 CLI try { const result = await exec('sqlite3', ['-json', '-readonly', dbPath, sql], { timeout: 10_000, From 7f0e130c74a64158522f6ab923cc2504ff1e4c87 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 9 Mar 2026 18:20:07 +0100 Subject: [PATCH 2/2] feat(agents): fetch live model list from /v1/llm_models on every agent startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of relying on the hardcoded OPENCODE_MODEL_CONFIGS, codemie-code and codemie-opencode now call /v1/llm_models?include_all=true at startup and build the model catalogue dynamically. - Add LlmModel interface + fetchCodeMieLlmModels() to sso.http-client.ts - Add opencode-dynamic-models.ts with fetchDynamicModelConfigs() and convertApiModelToOpenCodeConfig(); silently falls back to static configs on any auth/network error - Add RESPONSES_API_MODEL_PATTERNS to detect models that need the OpenAI Responses API when the /v1/llm_models endpoint provides no "mode" field - Update getChatCompletionsModelConfigs / getResponsesApiModelConfigs to accept an optional source map (backward-compatible default = static config) - Wire fetchDynamicModelConfigs into beforeRun of both plugins (JWT → SSO → static fallback auth chain) - Mock opencode-dynamic-models in plugin unit tests to avoid real API calls Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/codemie-code-plugin.test.ts | 5 + .../__tests__/codemie-code-reasoning.test.ts | 5 + src/agents/plugins/codemie-code.plugin.ts | 17 +- .../opencode/opencode-dynamic-models.ts | 187 ++++++++++++++++++ .../opencode/opencode-model-configs.ts | 18 +- .../plugins/opencode/opencode.plugin.ts | 18 +- src/providers/plugins/sso/sso.http-client.ts | 76 +++++++ 7 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 src/agents/plugins/opencode/opencode-dynamic-models.ts diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts index 9e02a976..ad721da4 100644 --- a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -106,6 +106,11 @@ vi.mock('../opencode/opencode-model-configs.js', () => ({ getResponsesApiModelConfigs: vi.fn(() => ({})), })); +// Mock dynamic model fetcher so tests don't make real API calls +vi.mock('../opencode/opencode-dynamic-models.js', () => ({ + fetchDynamicModelConfigs: vi.fn(() => Promise.resolve({})), +})); + // Mock fs vi.mock('fs', () => ({ existsSync: vi.fn(), diff --git a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts index 9b489369..f64c30ff 100644 --- a/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-reasoning.test.ts @@ -102,6 +102,11 @@ vi.mock('../opencode/opencode-model-configs.js', () => ({ getResponsesApiModelConfigs: vi.fn(() => ({})), })); +// Mock dynamic model fetcher so tests don't make real API calls +vi.mock('../opencode/opencode-dynamic-models.js', () => ({ + fetchDynamicModelConfigs: vi.fn(() => Promise.resolve({})), +})); + // Mock fs vi.mock('fs', () => ({ existsSync: vi.fn(() => true), diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index 3bd00ab0..5e64f970 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { existsSync } from 'fs'; import { logger } from '../../utils/logger.js'; import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode/opencode-model-configs.js'; +import { fetchDynamicModelConfigs } from './opencode/opencode-dynamic-models.js'; import { BaseAgentAdapter } from '../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../core/extension/BaseExtensionInstaller.js'; @@ -250,11 +251,21 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { return env; } + // Fetch live model catalogue from the CodeMie API. + // Falls back to the static OPENCODE_MODEL_CONFIGS on any error. + const allModels = await fetchDynamicModelConfigs( + baseUrl, + env.CODEMIE_URL, + env.CODEMIE_JWT_TOKEN, + ); + + // Model selection priority: env var > config > default + // Use dynamic catalogue first, then fall back to static getModelConfig for unknown IDs. const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; - const modelConfig = getModelConfig(selectedModel); + const modelConfig = allModels[selectedModel] ?? getModelConfig(selectedModel); const { providerOptions } = modelConfig; - const chatModels = getChatCompletionsModelConfigs(); - const responsesApiModels = getResponsesApiModelConfigs(); + const chatModels = getChatCompletionsModelConfigs(allModels); + const responsesApiModels = getResponsesApiModelConfigs(allModels); const isBedrock = provider === 'bedrock'; const isLiteLLM = provider === 'litellm'; diff --git a/src/agents/plugins/opencode/opencode-dynamic-models.ts b/src/agents/plugins/opencode/opencode-dynamic-models.ts new file mode 100644 index 00000000..9938e54c --- /dev/null +++ b/src/agents/plugins/opencode/opencode-dynamic-models.ts @@ -0,0 +1,187 @@ +/** + * Dynamic model list fetcher for OpenCode / CodeMie-Code + * + * Every time the agent starts, this module fetches the live model catalogue + * from the CodeMie API (/v1/llm_models?include_all=true) and converts it to + * the OpenCodeModelConfig format used throughout the plugin layer. + * + * Authentication priority (first available wins): + * 1. JWT Bearer token (env.CODEMIE_JWT_TOKEN) + * 2. SSO stored credentials (looked up by env.CODEMIE_URL) + * + * On any error (network, auth, parse) the module silently falls back to the + * static OPENCODE_MODEL_CONFIGS so agent startup is never blocked. + */ + +import type { LlmModel } from '../../../providers/plugins/sso/sso.http-client.js'; +import { fetchCodeMieLlmModels } from '../../../providers/plugins/sso/sso.http-client.js'; +import type { OpenCodeModelConfig } from './opencode-model-configs.js'; +import { OPENCODE_MODEL_CONFIGS } from './opencode-model-configs.js'; +import { CodeMieSSO } from '../../../providers/plugins/sso/sso.auth.js'; +import { logger } from '../../../utils/logger.js'; + +// ── Responses-API detection ────────────────────────────────────────────────── +// +// The /v1/llm_models endpoint does not expose a "mode" field, so we maintain +// an explicit list of model-name patterns that require OpenAI Responses API +// (POST /v1/responses) instead of Chat Completions (POST /v1/chat/completions). +// +// Naming conventions observed in CodeMie deployments: +// Responses API → gpt-5-2-*, gpt-5.2-*, gpt-5.x-codex-*, gpt-5-x-codex-* +// Chat Completions → gpt-4*, gpt-5--*, o1/o3/o4*, gemini-*, claude-*, … +// +// Update this list whenever new Responses-API-only models are deployed. + +const RESPONSES_API_MODEL_PATTERNS: RegExp[] = [ + /^gpt-5-2-/, // gpt-5-2-2025-12-11 (and future gpt-5-2-YYYY-* variants) + /^gpt-5\.2-/, // gpt-5.2-chat + /^gpt-5-1-codex/, // gpt-5-1-codex-2025-11-13 + /^gpt-5\.1-codex/, // gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max + /^gpt-5-3-codex/, // hyphenated variant of gpt-5.3-codex + /^gpt-5\.3-codex/, // gpt-5.3-codex-2026-02-24 +]; + +function isResponsesApiModel(id: string): boolean { + return RESPONSES_API_MODEL_PATTERNS.some(p => p.test(id)); +} + +// ── Family detection ───────────────────────────────────────────────────────── + +function detectFamily(id: string): string { + if (id.startsWith('claude')) return 'claude-4'; + if (id.startsWith('gemini')) return 'gemini-2'; + if (id.startsWith('gpt-4')) return 'gpt-4'; + if (id.startsWith('gpt-5')) return 'gpt-5'; + if (/^o[134]-/.test(id) || id === 'o1') return 'openai-reasoning'; + if (id.startsWith('qwen')) return 'qwen3'; + if (id.startsWith('deepseek')) return 'deepseek'; + if (id.startsWith('moonshotai') || id.startsWith('kimi')) return 'kimi'; + return id.split('-')[0] || id; +} + +// ── Token-limit heuristics ─────────────────────────────────────────────────── +// +// The /v1/llm_models endpoint does not include context/output token limits. +// We derive reasonable defaults from the model family. + +function detectLimits(id: string, family: string): { context: number; output: number } { + if (family === 'claude-4' || id.startsWith('claude')) return { context: 200000, output: 64000 }; + if (family === 'gemini-2' || id.startsWith('gemini')) return { context: 1048576, output: 65536 }; + if (id.startsWith('gpt-4.1')) return { context: 1048576, output: 32768 }; + if (id.startsWith('gpt-4o')) return { context: 128000, output: 16384 }; + if (id.startsWith('gpt-5')) return { context: 400000, output: 128000 }; + if (/^o[134]-/.test(id) || id === 'o1') return { context: 200000, output: 100000 }; + if (id.startsWith('qwen') || id.startsWith('moonshotai') || id.startsWith('kimi')) return { context: 262144, output: 131072 }; + if (id.startsWith('deepseek')) return { context: 65536, output: 65536 }; + return { context: 128000, output: 4096 }; +} + +// ── Conversion ─────────────────────────────────────────────────────────────── + +/** + * Convert a raw /v1/llm_models entry to an OpenCodeModelConfig. + * + * Cost conversion: API uses $/token; OpenCode uses $/million tokens. + * e.g. 0.000003 $/token → 3.0 $/M tokens + */ +export function convertApiModelToOpenCodeConfig(model: LlmModel): OpenCodeModelConfig { + const id = model.deployment_name; + const family = detectFamily(id); + const limit = detectLimits(id, family); + const responsesApi = isResponsesApiModel(id); + + const toPerMillion = (v: number | undefined) => (v ?? 0) * 1_000_000; + + const costInput = toPerMillion(model.cost?.input); + const costOutput = toPerMillion(model.cost?.output); + const cacheRead = model.cost?.cache_read_input_token_cost != null + ? toPerMillion(model.cost.cache_read_input_token_cost) + : undefined; + + const today = new Date().toISOString().split('T')[0]; + + return { + id, + name: model.label || id, + displayName: model.label || id, + family, + tool_call: model.features?.tools ?? true, + reasoning: true, + attachment: model.multimodal ?? false, + temperature: model.features?.temperature ?? true, + structured_output: model.features?.tools ? true : undefined, + ...(responsesApi && { use_responses_api: true }), + modalities: { + input: model.multimodal ? ['text', 'image'] : ['text'], + output: ['text'], + }, + knowledge: today, + release_date: today, + last_updated: today, + open_weights: false, + cost: { + input: costInput, + output: costOutput, + ...(cacheRead != null ? { cache_read: cacheRead } : {}), + }, + limit, + }; +} + +// ── Main export ────────────────────────────────────────────────────────────── + +/** + * Fetch the live model catalogue from the CodeMie API and convert it to + * OpenCodeModelConfig format. + * + * @param baseUrl - CODEMIE_BASE_URL (authenticated proxy endpoint) + * @param codeMieUrl - CODEMIE_URL (CodeMie org URL used for SSO credential lookup) + * @param jwtToken - CODEMIE_JWT_TOKEN (optional Bearer token, preferred over SSO) + * @returns Map of modelId → OpenCodeModelConfig (dynamic) or OPENCODE_MODEL_CONFIGS (fallback) + */ +export async function fetchDynamicModelConfigs( + baseUrl: string, + codeMieUrl: string | undefined, + jwtToken?: string, +): Promise> { + try { + let rawModels: LlmModel[]; + + if (jwtToken) { + rawModels = await fetchCodeMieLlmModels(baseUrl, jwtToken); + logger.debug('[dynamic-models] Fetched model list via JWT auth'); + } else if (codeMieUrl) { + const sso = new CodeMieSSO(); + const credentials = await sso.getStoredCredentials(codeMieUrl); + if (!credentials) { + logger.debug('[dynamic-models] No SSO credentials found, using static model configs'); + return OPENCODE_MODEL_CONFIGS; + } + rawModels = await fetchCodeMieLlmModels(credentials.apiUrl, credentials.cookies); + logger.debug('[dynamic-models] Fetched model list via SSO auth'); + } else { + logger.debug('[dynamic-models] No auth info in environment, using static model configs'); + return OPENCODE_MODEL_CONFIGS; + } + + const result: Record = {}; + for (const model of rawModels) { + if (!model.enabled) continue; + const config = convertApiModelToOpenCodeConfig(model); + result[config.id] = config; + } + + if (Object.keys(result).length === 0) { + logger.debug('[dynamic-models] API returned no enabled models, using static model configs'); + return OPENCODE_MODEL_CONFIGS; + } + + logger.debug(`[dynamic-models] Loaded ${Object.keys(result).length} models from API`); + return result; + } catch (error) { + logger.debug('[dynamic-models] Failed to fetch dynamic models, falling back to static model configs', { + error: error instanceof Error ? error.message : String(error), + }); + return OPENCODE_MODEL_CONFIGS; + } +} diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index 0ace76c0..20788d14 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -527,10 +527,15 @@ export function getAllOpenCodeModelConfigs(): Record> { +export function getChatCompletionsModelConfigs( + source: Record = OPENCODE_MODEL_CONFIGS +): Record> { const result: Record> = {}; - for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { + for (const [id, config] of Object.entries(source)) { if (config.use_responses_api) continue; const { displayName: _d, providerOptions: _p, use_responses_api: _r, ...opencodeConfig } = config; result[id] = opencodeConfig; @@ -542,10 +547,15 @@ export function getChatCompletionsModelConfigs(): Record> { +export function getResponsesApiModelConfigs( + source: Record = OPENCODE_MODEL_CONFIGS +): Record> { const result: Record> = {}; - for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { + for (const [id, config] of Object.entries(source)) { if (!config.use_responses_api) continue; const { displayName: _d, providerOptions: _p, use_responses_api: _r, ...opencodeConfig } = config; result[id] = opencodeConfig; diff --git a/src/agents/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index e5ec0829..fad59caf 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -1,6 +1,7 @@ import type { AgentMetadata, AgentConfig } from '../../core/types.js'; import { logger } from '../../../utils/logger.js'; import { getModelConfig, getChatCompletionsModelConfigs, getResponsesApiModelConfigs } from './opencode-model-configs.js'; +import { fetchDynamicModelConfigs } from './opencode-dynamic-models.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; @@ -63,15 +64,24 @@ export const OpenCodePluginMetadata: AgentMetadata = { return env; } + // Fetch live model catalogue from the CodeMie API. + // Falls back to the static OPENCODE_MODEL_CONFIGS on any error. + const allModels = await fetchDynamicModelConfigs( + baseUrl, + env.CODEMIE_URL, + env.CODEMIE_JWT_TOKEN, + ); + // Model selection priority: env var > config > default + // Use dynamic catalogue first, then fall back to static getModelConfig for unknown IDs. const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; - const modelConfig = getModelConfig(selectedModel); + const modelConfig = allModels[selectedModel] ?? getModelConfig(selectedModel); const { providerOptions } = modelConfig; - // Split models by API type - const chatModels = getChatCompletionsModelConfigs(); - const responsesApiModels = getResponsesApiModelConfigs(); + // Split models by API routing type + const chatModels = getChatCompletionsModelConfigs(allModels); + const responsesApiModels = getResponsesApiModelConfigs(allModels); // Determine URLs based on provider type const isBedrock = provider === 'bedrock'; diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 4a7c344a..32c8b737 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -82,6 +82,82 @@ export function ensureApiBase(rawUrl: string): string { return base; } +/** + * Full model descriptor returned by GET /v1/llm_models?include_all=true + */ +export interface LlmModel { + base_name: string; + deployment_name: string; + label: string; + multimodal?: boolean; + react_agent?: boolean; + enabled: boolean; + provider?: string; + default?: boolean; + cost?: { + input?: number; + output?: number; + cache_read_input_token_cost?: number; + cache_creation_input_token_cost?: number; + }; + features?: { + streaming?: boolean; + tools?: boolean; + temperature?: boolean; + parallel_tool_calls?: boolean; + system_prompt?: boolean; + max_tokens?: boolean; + top_p?: boolean; + }; + forbidden_for_web?: boolean; +} + +/** + * Fetch full model objects from /v1/llm_models?include_all=true (supports both cookies and JWT) + * + * Unlike fetchCodeMieModels (which returns only IDs), this returns the complete model + * descriptor including cost, features, and provider metadata. + * + * Overload 1: SSO cookies + * Overload 2: JWT token string + */ +/* eslint-disable no-redeclare */ +export function fetchCodeMieLlmModels( + apiUrl: string, + cookies: Record +): Promise; +export function fetchCodeMieLlmModels( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchCodeMieLlmModels( + apiUrl: string, + auth: Record | string +): Promise { +/* eslint-enable no-redeclare */ + const headers = buildAuthHeaders(auth); + const url = `${apiUrl}${CODEMIE_ENDPOINTS.MODELS}`; + + const client = new HTTPClient({ + timeout: 10000, + maxRetries: 3, + rejectUnauthorized: false, + }); + + const response = await client.getRaw(url, headers); + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode === 401 || response.statusCode === 403) { + throw new Error('Authentication failed - invalid or expired credentials'); + } + throw new Error(`Failed to fetch models: ${response.statusCode} ${response.statusMessage}`); + } + + const parsed = JSON.parse(response.data); + if (!Array.isArray(parsed)) return []; + return parsed as LlmModel[]; +} + /** * Fetch models from CodeMie API (supports both cookies and JWT) *