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