From 1dd520f770acd699b70fe5b3107736756ee67e34 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 14:04:23 +0100 Subject: [PATCH] feat: integration of models.dev and proper handling of outpout tokens --- drizzle/0008_third_cable.sql | 20 + drizzle/meta/0008_snapshot.json | 1237 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/main/database/schema.ts | 31 +- src/main/engine/ModelCatalogEngine.ts | 258 ++++ src/main/engine/OpenCodeManager.ts | 23 +- src/main/ipc/chatHandlers.ts | 26 + src/main/preload.ts | 4 + src/main/shared/electronApi.ts | 26 + .../components/SettingsView/SettingsView.css | 12 + .../components/SettingsView/SettingsView.tsx | 108 +- src/renderer/i18n/locales/de.json | 11 + src/renderer/i18n/locales/en.json | 11 + src/renderer/i18n/locales/es.json | 11 + src/renderer/i18n/locales/fr.json | 11 + src/renderer/i18n/locales/it.json | 11 + tests/engine/ModelCatalogEngine.test.ts | 276 ++++ tests/engine/OpenCodeManagerTools.test.ts | 32 + 18 files changed, 2101 insertions(+), 14 deletions(-) create mode 100644 drizzle/0008_third_cable.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/main/engine/ModelCatalogEngine.ts create mode 100644 tests/engine/ModelCatalogEngine.test.ts diff --git a/drizzle/0008_third_cable.sql b/drizzle/0008_third_cable.sql new file mode 100644 index 0000000..ff53169 --- /dev/null +++ b/drizzle/0008_third_cable.sql @@ -0,0 +1,20 @@ +CREATE TABLE `model_catalog` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `family` text, + `context_window` integer, + `max_input_tokens` integer, + `max_output_tokens` integer, + `input_price` real, + `output_price` real, + `cache_read_price` real, + `supports_attachments` integer DEFAULT false, + `supports_reasoning` integer DEFAULT false, + `supports_tool_call` integer DEFAULT false, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `model_catalog_meta` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..af16b9a --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1237 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dfaeea68-90b7-4d86-bb5b-90b2a69d71ec", + "prevId": "8b10ef6d-99dc-4772-8a32-66718f095dcf", + "tables": { + "chat_conversations": { + "name": "chat_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_session_id": { + "name": "copilot_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "db_notifications": { + "name": "db_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_cli": { + "name": "from_cli", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "generated_file_hashes": { + "name": "generated_file_hashes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "generated_file_hashes_project_path_idx": { + "name": "generated_file_hashes_project_path_idx", + "columns": [ + "project_id", + "relative_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_definitions": { + "name": "import_definitions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wxr_file_path": { + "name": "wxr_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploads_folder_path": { + "name": "uploads_folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_analysis_result": { + "name": "last_analysis_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sidecar_path": { + "name": "sidecar_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_catalog": { + "name": "model_catalog", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_price": { + "name": "input_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_price": { + "name": "output_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_price": { + "name": "cache_read_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supports_attachments": { + "name": "supports_attachments", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "supports_reasoning": { + "name": "supports_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "supports_tool_call": { + "name": "supports_tool_call", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_catalog_meta": { + "name": "model_catalog_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_links": { + "name": "post_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_post_id": { + "name": "source_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link_text": { + "name": "link_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_media": { + "name": "post_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_media_post_media_idx": { + "name": "post_media_post_media_idx", + "columns": [ + "post_id", + "media_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_slug": { + "name": "template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_title": { + "name": "published_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_content": { + "name": "published_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_tags": { + "name": "published_tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_categories": { + "name": "published_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_excerpt": { + "name": "published_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "posts_project_slug_idx": { + "name": "posts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_path": { + "name": "data_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scripts": { + "name": "scripts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'utility'" + }, + "entrypoint": { + "name": "entrypoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'render'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scripts_project_slug_idx": { + "name": "scripts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_template_slug": { + "name": "post_template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_project_name_idx": { + "name": "tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'post'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "templates_project_slug_idx": { + "name": "templates_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4b4f6c4..6b9c4c0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1772301340810, "tag": "0007_closed_sabretooth", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1772369331600, + "tag": "0008_third_cable", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 62cbbab..0bdcb1c 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core'; // Projects table - stores blog projects/websites export const projects = sqliteTable('projects', { @@ -206,6 +206,31 @@ export const dbNotifications = sqliteTable('db_notifications', { createdAt: integer('created_at').notNull(), }); +// Model catalog table - cached model metadata from models.dev API +// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider. +// Refreshed on user action via conditional GET (ETag). Survives offline use. +export const modelCatalog = sqliteTable('model_catalog', { + id: text('id').primaryKey(), // model ID (e.g. 'claude-sonnet-4-5') + name: text('name').notNull(), // display name + family: text('family'), // model family (e.g. 'claude-sonnet') + contextWindow: integer('context_window'), // max context tokens + maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context) + maxOutputTokens: integer('max_output_tokens'), // max output tokens + inputPrice: real('input_price'), // cost per 1M input tokens (USD) + outputPrice: real('output_price'), // cost per 1M output tokens (USD) + cacheReadPrice: real('cache_read_price'), // cost per 1M cached input tokens (USD) + supportsAttachments: integer('supports_attachments', { mode: 'boolean' }).default(false), + supportsReasoning: integer('supports_reasoning', { mode: 'boolean' }).default(false), + supportsToolCall: integer('supports_tool_call', { mode: 'boolean' }).default(false), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +// Model catalog HTTP cache metadata (ETag for conditional GET) +export const modelCatalogMeta = sqliteTable('model_catalog_meta', { + key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt' + value: text('value').notNull(), +}); + // Types for TypeScript export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -235,3 +260,7 @@ export type Template = typeof templates.$inferSelect; export type NewTemplate = typeof templates.$inferInsert; export type DbNotification = typeof dbNotifications.$inferSelect; export type NewDbNotification = typeof dbNotifications.$inferInsert; +export type ModelCatalogEntry = typeof modelCatalog.$inferSelect; +export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert; +export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect; +export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert; diff --git a/src/main/engine/ModelCatalogEngine.ts b/src/main/engine/ModelCatalogEngine.ts new file mode 100644 index 0000000..9d014f1 --- /dev/null +++ b/src/main/engine/ModelCatalogEngine.ts @@ -0,0 +1,258 @@ +/** + * ModelCatalogEngine — Fetches and caches model metadata from models.dev + * + * Provides model output token limits, pricing info, and capabilities + * for all models available through the OpenCode Zen gateway. + * + * Data is persisted in SQLite (model_catalog + model_catalog_meta tables) + * and refreshed on user action via conditional GET (ETag). + * Works fully offline after first successful fetch. + */ + +import https from 'https'; +import http from 'http'; +import { URL } from 'url'; +import { eq } from 'drizzle-orm'; +import { getDatabase } from '../database'; +import { modelCatalog, modelCatalogMeta } from '../database/schema'; +import type { ModelCatalogEntry } from '../database/schema'; + +const MODELS_DEV_URL = 'https://models.dev/api.json'; +const PROVIDER_KEY = 'opencode'; + +// Default max output tokens when no catalog data is available +export const DEFAULT_MAX_OUTPUT_TOKENS = 16384; + +export interface ModelCatalogInfo { + id: string; + name: string; + family: string | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + inputPrice: number | null; + outputPrice: number | null; + cacheReadPrice: number | null; + supportsAttachments: boolean | null; + supportsReasoning: boolean | null; + supportsToolCall: boolean | null; +} + +export interface RefreshResult { + success: boolean; + modelsUpdated: number; + notModified?: boolean; + error?: string; +} + +interface HttpResponse { + statusCode: number; + body: string; + headers: Record; +} + +export class ModelCatalogEngine { + /** + * Get all cached model catalog entries from the database. + */ + async getAll(): Promise { + const db = getDatabase().getLocal(); + const rows = await db.select().from(modelCatalog); + return rows.map(toInfo); + } + + /** + * Get a single model's catalog entry by ID. + */ + async getModel(modelId: string): Promise { + const db = getDatabase().getLocal(); + const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId)); + return rows.length > 0 ? toInfo(rows[0]) : null; + } + + /** + * Get the max output tokens for a model (used by OpenCodeManager for max_tokens). + * Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog. + */ + async getMaxOutputTokens(modelId: string): Promise { + const model = await this.getModel(modelId); + return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; + } + + /** + * Refresh the model catalog from models.dev using conditional GET (ETag). + * Returns the number of models updated, or notModified if the data hasn't changed. + */ + async refresh(): Promise { + try { + // Read stored ETag for conditional GET + const storedEtag = await this.getMeta('etag'); + + // Build request headers + const headers: Record = { 'Accept': 'application/json' }; + if (storedEtag) { + headers['If-None-Match'] = storedEtag; + } + + // Fetch from models.dev + const response = await this.httpGet(MODELS_DEV_URL, headers); + + // 304 Not Modified — data hasn't changed + if (response.statusCode === 304) { + await this.setMeta('lastFetchedAt', new Date().toISOString()); + return { success: true, modelsUpdated: 0, notModified: true }; + } + + if (response.statusCode !== 200) { + return { success: false, modelsUpdated: 0, error: `HTTP ${response.statusCode}` }; + } + + // Parse response + const data = JSON.parse(response.body); + const models = data?.[PROVIDER_KEY]?.models; + if (!models || typeof models !== 'object') { + return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' }; + } + + // Store new ETag + const newEtag = response.headers['etag']; + if (typeof newEtag === 'string') { + await this.setMeta('etag', newEtag); + } + await this.setMeta('lastFetchedAt', new Date().toISOString()); + + // Upsert all models + const count = await this.upsertModels(models); + + return { success: true, modelsUpdated: count }; + } catch (error) { + return { success: false, modelsUpdated: 0, error: (error as Error).message }; + } + } + + /** + * Get the last time the catalog was successfully fetched. + */ + async getLastFetchedAt(): Promise { + return this.getMeta('lastFetchedAt'); + } + + // ── Internal ── + + /** + * Parse models.dev model entries and upsert into database. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async upsertModels(models: Record): Promise { + const db = getDatabase().getLocal(); + const now = new Date(); + let count = 0; + + for (const [id, info] of Object.entries(models)) { + if (!info || typeof info !== 'object') continue; + + const entry = { + id, + name: info.name || id, + family: info.family || null, + contextWindow: info.limit?.context ?? null, + maxInputTokens: info.limit?.input ?? null, + maxOutputTokens: info.limit?.output ?? null, + inputPrice: info.cost?.input ?? null, + outputPrice: info.cost?.output ?? null, + cacheReadPrice: info.cost?.cache_read ?? null, + supportsAttachments: info.attachment ?? false, + supportsReasoning: info.reasoning ?? false, + supportsToolCall: info.tool_call ?? false, + updatedAt: now, + }; + + await db.insert(modelCatalog) + .values(entry) + .onConflictDoUpdate({ + target: modelCatalog.id, + set: { + name: entry.name, + family: entry.family, + contextWindow: entry.contextWindow, + maxInputTokens: entry.maxInputTokens, + maxOutputTokens: entry.maxOutputTokens, + inputPrice: entry.inputPrice, + outputPrice: entry.outputPrice, + cacheReadPrice: entry.cacheReadPrice, + supportsAttachments: entry.supportsAttachments, + supportsReasoning: entry.supportsReasoning, + supportsToolCall: entry.supportsToolCall, + updatedAt: now, + }, + }); + count++; + } + + return count; + } + + private async getMeta(key: string): Promise { + const db = getDatabase().getLocal(); + const rows = await db.select().from(modelCatalogMeta).where(eq(modelCatalogMeta.key, key)); + return rows.length > 0 ? rows[0].value : null; + } + + private async setMeta(key: string, value: string): Promise { + const db = getDatabase().getLocal(); + await db.insert(modelCatalogMeta) + .values({ key, value }) + .onConflictDoUpdate({ target: modelCatalogMeta.key, set: { value } }); + } + + private httpGet( + urlStr: string, + headers: Record, + ): Promise { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const protocol = url.protocol === 'https:' ? https : http; + + const req = protocol.request(url, { + method: 'GET', + headers, + timeout: 15000, + }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode || 0, + body, + headers: res.headers as Record, + }); + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + + req.end(); + }); + } +} + +function toInfo(row: ModelCatalogEntry): ModelCatalogInfo { + return { + id: row.id, + name: row.name, + family: row.family, + contextWindow: row.contextWindow, + maxInputTokens: row.maxInputTokens, + maxOutputTokens: row.maxOutputTokens, + inputPrice: row.inputPrice, + outputPrice: row.outputPrice, + cacheReadPrice: row.cacheReadPrice, + supportsAttachments: row.supportsAttachments, + supportsReasoning: row.supportsReasoning, + supportsToolCall: row.supportsToolCall, + }; +} diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index e24b0c5..5cfa531 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -24,6 +24,7 @@ import { ChatEngine } from './ChatEngine'; import { PostEngine, type PostData } from './PostEngine'; import { MediaEngine, type MediaData } from './MediaEngine'; import type { PostMediaEngine } from './PostMediaEngine'; +import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine'; import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; import type { A2UIServerMessage } from '../a2ui/types'; @@ -76,6 +77,8 @@ const MODEL_DISPLAY_NAMES: Record = { 'trinity-large-preview-free': 'Trinity Large Preview Free', }; + + // Uppercase prefixes that should not be title-cased const UPPERCASE_PREFIXES = ['gpt', 'glm']; @@ -172,6 +175,7 @@ export class OpenCodeManager { private cachedModels: ModelInfo[] | null = null; private cachedModelsAt: number = 0; private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private modelCatalogEngine = new ModelCatalogEngine(); private conversationUsage: Map = { model: modelId, - max_tokens: 4096, + max_tokens: await this.getMaxOutputTokens(modelId), system: systemPrompt, messages, tools, @@ -793,7 +797,7 @@ export class OpenCodeManager { const body: Record = { model: modelId, - max_tokens: 4096, + max_tokens: await this.getMaxOutputTokens(modelId), messages, tools: openaiTools, stream: true, @@ -1977,6 +1981,21 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all } } + /** + * Get max output tokens for a model from the model catalog (DB-backed). + * Falls back to DEFAULT_MAX_OUTPUT_TOKENS (16384) when not catalogued. + */ + private async getMaxOutputTokens(modelId: string): Promise { + return this.modelCatalogEngine.getMaxOutputTokens(modelId); + } + + /** + * Access the model catalog engine (used by IPC handlers). + */ + getModelCatalogEngine(): ModelCatalogEngine { + return this.modelCatalogEngine; + } + private detectProvider(modelId: string): string { const id = modelId.toLowerCase(); if (id.startsWith('claude')) return 'anthropic'; diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 6bf06fa..cea3395 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -212,6 +212,32 @@ export function registerChatHandlers(): void { } }); + // ============ Model Catalog ============ + + // Refresh model catalog from models.dev (conditional GET with ETag) + ipcMain.handle('chat:refreshModelCatalog', async () => { + try { + const manager = await getOpenCodeManager(); + const result = await manager.getModelCatalogEngine().refresh(); + return result; + } catch (error) { + console.error('[Chat IPC] Error refreshing model catalog:', error); + return { success: false, modelsUpdated: 0, error: (error as Error).message }; + } + }); + + // Get all model catalog entries + ipcMain.handle('chat:getModelCatalog', async () => { + try { + const manager = await getOpenCodeManager(); + const entries = await manager.getModelCatalogEngine().getAll(); + return { success: true, entries }; + } catch (error) { + console.error('[Chat IPC] Error getting model catalog:', error); + return { success: false, entries: [], error: (error as Error).message }; + } + }); + // ============ Conversation CRUD ============ // Get all conversations diff --git a/src/main/preload.ts b/src/main/preload.ts index 0fa8aa8..57afbf5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -315,6 +315,10 @@ export const electronAPI: ElectronAPI = { getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'), setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt), + // Model Catalog + refreshModelCatalog: () => ipcRenderer.invoke('chat:refreshModelCatalog'), + getModelCatalog: () => ipcRenderer.invoke('chat:getModelCatalog'), + // Conversations getConversations: () => ipcRenderer.invoke('chat:getConversations'), createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index e91edcd..c75b41e 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -424,6 +424,28 @@ export interface ChatModel { provider?: string; } +export interface ModelCatalogEntry { + id: string; + name: string; + family: string | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + inputPrice: number | null; + outputPrice: number | null; + cacheReadPrice: number | null; + supportsAttachments: boolean | null; + supportsReasoning: boolean | null; + supportsToolCall: boolean | null; +} + +export interface ModelCatalogRefreshResult { + success: boolean; + modelsUpdated: number; + notModified?: boolean; + error?: string; +} + export interface ChatReadyStatus { ready: boolean; error?: string; @@ -809,6 +831,10 @@ export interface ElectronAPI { getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>; + // Model Catalog + refreshModelCatalog: () => Promise; + getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>; + // Conversations getConversations: () => Promise; createConversation: (title?: string, model?: string) => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index 2f6d632..f4e2f2b 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -242,6 +242,18 @@ flex: 1; } +.setting-input-group select { + flex: 1; +} + +/* Model catalog metadata line */ +.model-catalog-info { + font-size: 11px; + color: var(--vscode-descriptionForeground, #888); + padding: 4px 0 0; + line-height: 1.4; +} + .setting-toggle-visibility { background: transparent; border: none; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 16b6b06..fa95e99 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -244,6 +244,13 @@ export const SettingsView: React.FC = () => { const [newApiKey, setNewApiKey] = useState(''); const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]); const [selectedModel, setSelectedModel] = useState(''); + const [modelCatalog, setModelCatalog] = useState>(new Map()); + const [refreshingCatalog, setRefreshingCatalog] = useState(false); // Check if a section has any matching settings const sectionHasMatches = useCallback((sectionKeywords: string[]) => { @@ -395,6 +402,21 @@ export const SettingsView: React.FC = () => { setAvailableModels(modelsResult.models); setSelectedModel(modelsResult.selectedModel || ''); } + + // Load model catalog metadata + const catalogResult = await window.electronAPI?.chat.getModelCatalog(); + if (catalogResult?.success && catalogResult.entries) { + const map = new Map(); + for (const entry of catalogResult.entries) { + map.set(entry.id, { + maxOutputTokens: entry.maxOutputTokens, + contextWindow: entry.contextWindow, + inputPrice: entry.inputPrice, + outputPrice: entry.outputPrice, + }); + } + setModelCatalog(map); + } } catch (error) { console.error('Failed to load AI settings:', error); } @@ -1080,6 +1102,41 @@ export const SettingsView: React.FC = () => { } }; + const handleRefreshModelCatalog = async () => { + setRefreshingCatalog(true); + try { + const result = await window.electronAPI?.chat.refreshModelCatalog(); + if (result?.success) { + if (result.notModified) { + showToast.success(t('settings.toast.modelCatalogUpToDate')); + } else { + showToast.success(t('settings.toast.modelCatalogRefreshed', { count: String(result.modelsUpdated) })); + } + // Reload catalog data + const catalogResult = await window.electronAPI?.chat.getModelCatalog(); + if (catalogResult?.success && catalogResult.entries) { + const map = new Map(); + for (const entry of catalogResult.entries) { + map.set(entry.id, { + maxOutputTokens: entry.maxOutputTokens, + contextWindow: entry.contextWindow, + inputPrice: entry.inputPrice, + outputPrice: entry.outputPrice, + }); + } + setModelCatalog(map); + } + } else { + showToast.error(t('settings.toast.modelCatalogRefreshFailed')); + } + } catch (error) { + console.error('Failed to refresh model catalog:', error); + showToast.error(t('settings.toast.modelCatalogRefreshFailed')); + } finally { + setRefreshingCatalog(false); + } + }; + const renderAISettings = () => ( { label={t('settings.ai.defaultModelLabel')} description={t('settings.ai.defaultModelDescription')} > - +
+ + +
+ {selectedModel && modelCatalog.has(selectedModel) && (() => { + const info = modelCatalog.get(selectedModel)!; + const parts: string[] = []; + if (info.maxOutputTokens != null) { + parts.push(`${t('settings.ai.modelInfoMaxOutput')}: ${info.maxOutputTokens.toLocaleString()} ${t('settings.ai.modelInfoTokens')}`); + } + if (info.contextWindow != null) { + parts.push(`${t('settings.ai.modelInfoContext')}: ${info.contextWindow.toLocaleString()} ${t('settings.ai.modelInfoTokens')}`); + } + if (info.inputPrice != null) { + parts.push(`${t('settings.ai.modelInfoInputPrice')}: $${info.inputPrice}${t('settings.ai.modelInfoPerMTok')}`); + } + if (info.outputPrice != null) { + parts.push(`${t('settings.ai.modelInfoOutputPrice')}: $${info.outputPrice}${t('settings.ai.modelInfoPerMTok')}`); + } + return parts.length > 0 ? ( +
{parts.join(' · ')}
+ ) : null; + })()} = { + from: vi.fn().mockImplementation(() => chain), + where: vi.fn().mockImplementation(() => chain), + orderBy: vi.fn().mockImplementation(() => chain), + then: (resolve: (v: unknown) => void) => Promise.resolve(mockData).then(resolve), + }; + return chain; +} + +let selectMockData: unknown[] = []; +const insertedValues: unknown[] = []; + +function createDrizzleMock() { + return { + select: vi.fn(() => createSelectChain(selectMockData)), + insert: vi.fn(() => ({ + values: vi.fn((data: unknown) => { + insertedValues.push(data); + return { + onConflictDoUpdate: vi.fn(() => Promise.resolve()), + then: (resolve: (v: unknown) => void) => Promise.resolve().then(resolve), + }; + }), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + }; +} + +const mockLocalDb = createDrizzleMock(); + +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => mockLocalDb), + })), +})); + +import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine'; + +// ── Sample models.dev response ── + +function sampleModelsDevResponse() { + return { + opencode: { + id: 'opencode', + models: { + 'claude-sonnet-4-5': { + id: 'claude-sonnet-4-5', + name: 'Claude Sonnet 4.5', + family: 'claude-sonnet', + attachment: true, + reasoning: false, + tool_call: true, + cost: { input: 3, output: 15, cache_read: 0.3 }, + limit: { context: 200000, output: 64000 }, + }, + 'gpt-5': { + id: 'gpt-5', + name: 'GPT 5', + family: 'gpt', + attachment: true, + reasoning: true, + tool_call: true, + cost: { input: 1.07, output: 8.5, cache_read: 0.107 }, + limit: { context: 400000, input: 272000, output: 128000 }, + }, + 'model-no-cost': { + id: 'model-no-cost', + name: 'Free Model', + family: 'free', + limit: { context: 32000, output: 4096 }, + }, + }, + }, + }; +} + +describe('ModelCatalogEngine', () => { + let engine: ModelCatalogEngine; + + beforeEach(() => { + vi.clearAllMocks(); + selectMockData = []; + insertedValues.length = 0; + engine = new ModelCatalogEngine(); + }); + + describe('getAll', () => { + it('returns all cached model catalog entries', async () => { + selectMockData = [ + { + id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet', + contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000, + inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, + supportsAttachments: true, supportsReasoning: false, supportsToolCall: true, + }, + ]; + + const result = await engine.getAll(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('claude-sonnet-4-5'); + expect(result[0].maxOutputTokens).toBe(64000); + expect(result[0].inputPrice).toBe(3); + }); + + it('returns empty array when no catalog entries exist', async () => { + selectMockData = []; + const result = await engine.getAll(); + expect(result).toEqual([]); + }); + }); + + describe('getModel', () => { + it('returns a specific model by ID', async () => { + selectMockData = [{ + id: 'gpt-5', name: 'GPT 5', family: 'gpt', + contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000, + inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, + supportsAttachments: true, supportsReasoning: true, supportsToolCall: true, + }]; + + const result = await engine.getModel('gpt-5'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('GPT 5'); + expect(result!.maxOutputTokens).toBe(128000); + }); + + it('returns null for unknown model', async () => { + selectMockData = []; + const result = await engine.getModel('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('getMaxOutputTokens', () => { + it('returns output tokens from catalog when available', async () => { + selectMockData = [{ + id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet', + contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000, + inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, + supportsAttachments: true, supportsReasoning: false, supportsToolCall: true, + }]; + + const result = await engine.getMaxOutputTokens('claude-sonnet-4-5'); + expect(result).toBe(64000); + }); + + it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => { + selectMockData = []; + const result = await engine.getMaxOutputTokens('unknown-model'); + expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS); + }); + + it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => { + selectMockData = [{ + id: 'weird-model', name: 'Weird', family: null, + contextWindow: null, maxInputTokens: null, maxOutputTokens: null, + inputPrice: null, outputPrice: null, cacheReadPrice: null, + supportsAttachments: false, supportsReasoning: false, supportsToolCall: false, + }]; + + const result = await engine.getMaxOutputTokens('weird-model'); + expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS); + }); + }); + + describe('refresh', () => { + it('parses models.dev response and inserts models into DB', async () => { + const mockResponse = sampleModelsDevResponse(); + vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ + statusCode: 200, + body: JSON.stringify(mockResponse), + headers: { etag: '"abc123"' }, + }); + + // getMeta returns null (no existing etag) + selectMockData = []; + + const result = await engine.refresh(); + expect(result.success).toBe(true); + expect(result.modelsUpdated).toBe(3); + expect(result.notModified).toBeUndefined(); + }); + + it('sends If-None-Match header when ETag is cached', async () => { + const httpGetSpy = vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ + statusCode: 304, + body: '', + headers: {}, + }); + + // Return stored etag on first getMeta call + let metaCallCount = 0; + const origSelect = mockLocalDb.select; + mockLocalDb.select = vi.fn(() => { + metaCallCount++; + if (metaCallCount === 1) { + return createSelectChain([{ key: 'etag', value: '"old-etag"' }]); + } + return createSelectChain([]); + }) as any; + + const result = await engine.refresh(); + + expect(result.success).toBe(true); + expect(result.notModified).toBe(true); + expect(httpGetSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ 'If-None-Match': '"old-etag"' }), + ); + + mockLocalDb.select = origSelect; + }); + + it('handles HTTP errors gracefully', async () => { + vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ + statusCode: 500, + body: 'Internal Server Error', + headers: {}, + }); + selectMockData = []; + + const result = await engine.refresh(); + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 500'); + }); + + it('handles network errors gracefully', async () => { + vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED')); + selectMockData = []; + + const result = await engine.refresh(); + expect(result.success).toBe(false); + expect(result.error).toBe('ECONNREFUSED'); + }); + + it('handles invalid response (missing opencode provider)', async () => { + vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ + statusCode: 200, + body: JSON.stringify({ other_provider: { models: {} } }), + headers: {}, + }); + selectMockData = []; + + const result = await engine.refresh(); + expect(result.success).toBe(false); + expect(result.error).toContain('no opencode models'); + }); + + it('handles malformed JSON gracefully', async () => { + vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ + statusCode: 200, + body: 'not valid json {{{', + headers: {}, + }); + selectMockData = []; + + const result = await engine.refresh(); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/tests/engine/OpenCodeManagerTools.test.ts b/tests/engine/OpenCodeManagerTools.test.ts index c173ff7..f197d7a 100644 --- a/tests/engine/OpenCodeManagerTools.test.ts +++ b/tests/engine/OpenCodeManagerTools.test.ts @@ -200,3 +200,35 @@ describe('OpenCodeManager tool execution – backlinks & linksTo', () => { }); }); }); + +describe('OpenCodeManager – getMaxOutputTokens (ModelCatalogEngine delegate)', () => { + let manager: OpenCodeManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createManager(createMockPostEngine()); + }); + + it('delegates to ModelCatalogEngine.getMaxOutputTokens', async () => { + const engine = (manager as any).modelCatalogEngine; + vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(64000); + + const result = await (manager as any).getMaxOutputTokens('claude-sonnet-4-5'); + expect(result).toBe(64000); + expect(engine.getMaxOutputTokens).toHaveBeenCalledWith('claude-sonnet-4-5'); + }); + + it('returns default when ModelCatalogEngine has no data', async () => { + const engine = (manager as any).modelCatalogEngine; + vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(16384); + + const result = await (manager as any).getMaxOutputTokens('unknown-model'); + expect(result).toBe(16384); + }); + + it('exposes ModelCatalogEngine via getModelCatalogEngine()', () => { + const engine = manager.getModelCatalogEngine(); + expect(engine).toBeDefined(); + expect(engine).toBeInstanceOf(Object); + }); +});