From 7ef476cfbdbf8468eab966a5ae4e7ecddff4367d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20Lindahl?= Date: Wed, 1 Apr 2026 23:48:16 +0200 Subject: [PATCH] feat(zen): add live model sync --- conf/zen_models_live.json | 696 +++++++++++++++++++++++++++++++ docs/configuration.md | 5 +- docs/custom_models.md | 47 ++- providers/registries/zen.py | 90 +++- scripts/sync_zen_models.py | 237 +++++++++++ tests/test_sync_zen_models.py | 61 +++ tests/test_zen_model_registry.py | 173 +++++++- 7 files changed, 1300 insertions(+), 9 deletions(-) create mode 100644 conf/zen_models_live.json create mode 100644 scripts/sync_zen_models.py create mode 100644 tests/test_sync_zen_models.py diff --git a/conf/zen_models_live.json b/conf/zen_models_live.json new file mode 100644 index 0000000..33c4a88 --- /dev/null +++ b/conf/zen_models_live.json @@ -0,0 +1,696 @@ +{ + "_README": { + "description": "Generated baseline OpenCode Zen catalogue for PAL MCP Server.", + "source": "https://opencode.ai/zen/v1/models", + "usage": "Generated by scripts/sync_zen_models.py. Curated overrides belong in conf/zen_models.json.", + "field_notes": "Entries are conservative discovery data. Curated manifest values override these at runtime." + }, + "models": [ + { + "model_name": "big-pickle", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 32000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Big Pickle via OpenCode Zen - Stealth model for coding tasks", + "intelligence_score": 13, + "allow_code_generation": true + }, + { + "model_name": "claude-3-5-haiku", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model claude-3-5-haiku.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "claude-haiku-4-5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 5.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Claude Haiku 4.5 via OpenCode Zen - Fast and efficient for coding tasks", + "intelligence_score": 16, + "allow_code_generation": true + }, + { + "model_name": "claude-opus-4-1", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model claude-opus-4-1.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "claude-opus-4-5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 5.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Claude Opus 4.5 via OpenCode Zen - Anthropic's frontier reasoning model for complex software engineering", + "intelligence_score": 18, + "allow_code_generation": true + }, + { + "model_name": "claude-opus-4-6", + "aliases": [], + "context_window": 1000000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model claude-opus-4-6.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "claude-sonnet-4", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model claude-sonnet-4.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "claude-sonnet-4-5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 5.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Claude Sonnet 4.5 via OpenCode Zen - Balanced performance for coding and general tasks", + "intelligence_score": 17, + "allow_code_generation": true + }, + { + "model_name": "claude-sonnet-4-6", + "aliases": [], + "context_window": 1000000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model claude-sonnet-4-6.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "gemini-3-flash", + "aliases": [], + "context_window": 1048576, + "max_output_tokens": 65536, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gemini-3-flash.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "gemini-3-pro", + "aliases": [], + "context_window": 1000000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 10.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Gemini 3 Pro via OpenCode Zen - Google's multimodal model with large context", + "intelligence_score": 16, + "allow_code_generation": true + }, + { + "model_name": "gemini-3.1-pro", + "aliases": [], + "context_window": 1048576, + "max_output_tokens": 65536, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gemini-3.1-pro.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "glm-4.6", + "aliases": [], + "context_window": 205000, + "max_output_tokens": 32000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "GLM 4.6 via OpenCode Zen - High-performance model for coding and reasoning", + "intelligence_score": 15, + "allow_code_generation": true + }, + { + "model_name": "glm-4.7", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model glm-4.7.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "glm-5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model glm-5.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "gpt-5", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5-codex", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5-codex.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5-nano", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 32000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "GPT 5 Nano via OpenCode Zen - Lightweight GPT model", + "intelligence_score": 12, + "allow_code_generation": true + }, + { + "model_name": "gpt-5.1", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "GPT 5.1 via OpenCode Zen - Latest GPT model for general AI tasks", + "intelligence_score": 16, + "allow_code_generation": true, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.1-codex", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "GPT 5.1 Codex via OpenCode Zen - Specialized for code generation and understanding", + "intelligence_score": 17, + "allow_code_generation": true, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.1-codex-max", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.1-codex-max.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.1-codex-mini", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.1-codex-mini.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.2", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.2.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.2-codex", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.2-codex.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.3-codex", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.3-codex.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.3-codex-spark", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.3-codex-spark.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.4", + "aliases": [], + "context_window": 1050000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.4.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.4-mini", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.4-mini.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.4-nano", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.4-nano.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "gpt-5.4-pro", + "aliases": [], + "context_window": 1050000, + "max_output_tokens": 128000, + "supports_extended_thinking": true, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": true, + "max_image_size_mb": 20.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model gpt-5.4-pro.", + "intelligence_score": 10, + "allow_code_generation": false, + "use_openai_response_api": true + }, + { + "model_name": "kimi-k2", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 32000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Kimi K2 via OpenCode Zen - Advanced reasoning model", + "intelligence_score": 15, + "allow_code_generation": true + }, + { + "model_name": "kimi-k2-thinking", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model kimi-k2-thinking.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "kimi-k2.5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model kimi-k2.5.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "mimo-v2-flash-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model mimo-v2-flash-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "mimo-v2-omni-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model mimo-v2-omni-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "mimo-v2-pro-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model mimo-v2-pro-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "minimax-m2.1", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model minimax-m2.1.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "minimax-m2.5", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model minimax-m2.5.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "minimax-m2.5-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model minimax-m2.5-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "nemotron-3-super-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model nemotron-3-super-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "qwen3.6-plus-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model qwen3.6-plus-free.", + "intelligence_score": 10, + "allow_code_generation": false + }, + { + "model_name": "trinity-large-preview-free", + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": false, + "supports_json_mode": true, + "supports_function_calling": true, + "supports_images": false, + "max_image_size_mb": 0.0, + "supports_temperature": true, + "temperature_constraint": "range", + "description": "Generated baseline metadata for OpenCode Zen model trinity-large-preview-free.", + "intelligence_score": 10, + "allow_code_generation": false + } + ] +} diff --git a/docs/configuration.md b/docs/configuration.md index 7497905..214ed8c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,6 +79,8 @@ DEFAULT_MODEL=auto # Claude picks best model for each task (recommended) - `conf/openai_models.json` – OpenAI catalogue (can be overridden with `OPENAI_MODELS_CONFIG_PATH`) - `conf/gemini_models.json` – Gemini catalogue (`GEMINI_MODELS_CONFIG_PATH`) - `conf/xai_models.json` – X.AI / GROK catalogue (`XAI_MODELS_CONFIG_PATH`) + - `conf/zen_models.json` – Curated OpenCode Zen overrides (`ZEN_MODELS_CONFIG_PATH`) + - `conf/zen_models_live.json` – Generated live OpenCode Zen catalogue (`ZEN_LIVE_MODELS_CONFIG_PATH`) - `conf/openrouter_models.json` – Curated OpenRouter overrides (`OPENROUTER_MODELS_CONFIG_PATH`) - `conf/openrouter_models_live.json` – Generated live OpenRouter catalogue (`OPENROUTER_LIVE_MODELS_CONFIG_PATH`) - `conf/dial_models.json` – DIAL aggregation catalogue (`DIAL_MODELS_CONFIG_PATH`) @@ -93,10 +95,11 @@ DEFAULT_MODEL=auto # Claude picks best model for each task (recommended) | OpenAI | `gpt-5.2`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5`, `gpt-5.2-pro`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-codex`, `gpt-4.1`, `o3`, `o3-mini`, `o3-pro`, `o4-mini` | `gpt5.2`, `gpt-5.2`, `5.2`, `gpt5.1-codex`, `codex-5.1`, `codex-mini`, `gpt5`, `gpt5pro`, `mini`, `nano`, `codex`, `o3mini`, `o3pro`, `o4mini` | | Gemini | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`, `gemini-2.0-flash-lite` | `pro`, `gemini-pro`, `flash`, `flash-2.0`, `flashlite` | | X.AI | `grok-4`, `grok-4.1-fast` | `grok`, `grok4`, `grok-4.1-fast-reasoning` | + | OpenCode Zen | Generated live catalogue plus curated overrides | e.g., `zen-sonnet`, `zen-codex`, plus any curated aliases you add | | OpenRouter | Generated live catalogue plus curated overrides | e.g., `opus`, `sonnet`, `flash`, `pro`, `mistral` | | Custom | User-managed entries such as `llama3.2` | Define your own aliases per entry | - Latest OpenAI entries (`gpt-5.2`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5.2-pro`) expose 400K-token contexts with large outputs, reasoning-token support, and multimodal inputs. `gpt-5.1-codex` and `gpt-5.2-pro` are Responses-only with streaming disabled, while the base `gpt-5.2` and Codex mini support streaming along with full code-generation flags. For OpenRouter, keep PAL-specific metadata in the curated manifest and regenerate the live catalogue when OpenRouter adds or removes models; see [Refreshing the Live OpenRouter Catalogue](custom_models.md#refreshing-the-live-openrouter-catalogue). + Latest OpenAI entries (`gpt-5.2`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5.2-pro`) expose 400K-token contexts with large outputs, reasoning-token support, and multimodal inputs. `gpt-5.1-codex` and `gpt-5.2-pro` are Responses-only with streaming disabled, while the base `gpt-5.2` and Codex mini support streaming along with full code-generation flags. For OpenCode Zen and OpenRouter, keep PAL-specific metadata in the curated manifest and regenerate the live catalogue when the upstream provider adds or removes models; see [Refreshing the Live OpenCode Zen Catalogue](custom_models.md#refreshing-the-live-opencode-zen-catalogue) and [Refreshing the Live OpenRouter Catalogue](custom_models.md#refreshing-the-live-openrouter-catalogue). > **Tip:** Copy the JSON file you need, customise it, and point the corresponding `*_MODELS_CONFIG_PATH` environment variable to your version. This lets you enable or disable capabilities (JSON mode, function calling, temperature support, code generation) without editing Python. diff --git a/docs/custom_models.md b/docs/custom_models.md index c3c2f54..0157cf0 100644 --- a/docs/custom_models.md +++ b/docs/custom_models.md @@ -221,13 +221,15 @@ CUSTOM_MODEL_NAME=your-loaded-model The system automatically routes models to the appropriate provider: 1. Entries in `conf/custom_models.json` → Always routed through the Custom API (requires `CUSTOM_API_URL`) -2. Entries in `conf/openrouter_models_live.json` and `conf/openrouter_models.json` → Routed through OpenRouter (requires `OPENROUTER_API_KEY`) -3. **Unknown models** → Fallback logic based on model name patterns +2. Entries in `conf/zen_models_live.json` and `conf/zen_models.json` → Routed through OpenCode Zen (requires `ZEN_API_KEY`) +3. Entries in `conf/openrouter_models_live.json` and `conf/openrouter_models.json` → Routed through OpenRouter (requires `OPENROUTER_API_KEY`) +4. **Unknown models** → Fallback logic based on model name patterns **Provider Priority Order:** 1. Native APIs (Google, OpenAI) - if API keys are available -2. Custom endpoints - for models declared in `conf/custom_models.json` -3. OpenRouter - catch-all for cloud models +2. OpenCode Zen - curated gateway models +3. Custom endpoints - for models declared in `conf/custom_models.json` +4. OpenRouter - catch-all for cloud models This ensures clean separation between local and cloud models while maintaining flexibility for unknown models. @@ -241,7 +243,42 @@ These JSON files define model aliases and capabilities. You can: ### Adding Custom Models -Edit `conf/openrouter_models.json` to tweak OpenRouter behaviour or `conf/custom_models.json` to add local models. The generated `conf/openrouter_models_live.json` file is discovery data from OpenRouter's `/api/v1/models` endpoint; curated entries in `conf/openrouter_models.json` override those generated defaults. Each entry maps directly onto [`ModelCapabilities`](../providers/shared/model_capabilities.py). +Edit `conf/zen_models.json` to tweak OpenCode Zen behaviour, `conf/openrouter_models.json` to tweak OpenRouter behaviour, or `conf/custom_models.json` to add local models. The generated `conf/zen_models_live.json` and `conf/openrouter_models_live.json` files are discovery data from the upstream model listing endpoints; curated entries override those generated defaults. Each entry maps directly onto [`ModelCapabilities`](../providers/shared/model_capabilities.py). + +### Refreshing the Live OpenCode Zen Catalogue + +Run the sync script whenever OpenCode Zen adds or removes models that you want `listmodels` and provider enumeration to expose, or before cutting a release that should include an updated Zen catalogue. + +```bash +source .pal_venv/bin/activate +python scripts/sync_zen_models.py +``` + +By default the script: + +- fetches `https://opencode.ai/zen/v1/models` +- writes conservative discovery data to `conf/zen_models_live.json` +- leaves `conf/zen_models.json` untouched + +Use the optional flags if you need to test against a different endpoint or write to a different file: + +```bash +python scripts/sync_zen_models.py --url https://opencode.ai/zen/v1/models --output conf/zen_models_live.json +``` + +Important runtime behavior: + +- `conf/zen_models_live.json` is the generated baseline catalogue +- `conf/zen_models.json` is the curated override layer for aliases and PAL-specific capability flags +- curated entries win when the same `model_name` appears in both files +- models missing from the curated file are still available from the generated catalogue + +After refreshing the catalogue: + +1. Review the diff in `conf/zen_models_live.json` +2. Add or update curated entries in `conf/zen_models.json` if a new model needs aliases or PAL-specific capability tweaks +3. Restart the server so the updated manifests are reloaded +4. Commit the generated JSON alongside any curated overrides so other contributors get the same catalogue state ### Refreshing the Live OpenRouter Catalogue diff --git a/providers/registries/zen.py b/providers/registries/zen.py index a024cae..8715c1d 100644 --- a/providers/registries/zen.py +++ b/providers/registries/zen.py @@ -2,14 +2,48 @@ from __future__ import annotations +import importlib.resources +import json +import logging +from pathlib import Path + +from utils.env import get_env +from utils.file_utils import read_json_file + from ..shared import ModelCapabilities, ProviderType from .base import CAPABILITY_FIELD_NAMES, CapabilityModelRegistry +logger = logging.getLogger(__name__) + + class ZenModelRegistry(CapabilityModelRegistry): """Capability registry backed by ``conf/zen_models.json``.""" - def __init__(self, config_path: str | None = None) -> None: + LIVE_ENV_VAR_NAME = "ZEN_LIVE_MODELS_CONFIG_PATH" + LIVE_DEFAULT_FILENAME = "zen_models_live.json" + + def __init__(self, config_path: str | None = None, live_config_path: str | None = None) -> None: + self._live_resource = None + self._live_config_path: Path | None = None + self._live_default_path = Path(__file__).resolve().parents[3] / "conf" / self.LIVE_DEFAULT_FILENAME + + if live_config_path: + self._live_config_path = Path(live_config_path) + else: + env_path = get_env(self.LIVE_ENV_VAR_NAME) + if env_path: + self._live_config_path = Path(env_path) + else: + try: + resource = importlib.resources.files("conf").joinpath(self.LIVE_DEFAULT_FILENAME) + if hasattr(resource, "read_text"): + self._live_resource = resource + else: + raise AttributeError("resource accessor not available") + except Exception: + self._live_config_path = self._live_default_path + super().__init__( env_var_name="ZEN_MODELS_CONFIG_PATH", default_filename="zen_models.json", @@ -18,6 +52,60 @@ class ZenModelRegistry(CapabilityModelRegistry): config_path=config_path, ) + def reload(self) -> None: + live_data = self._load_live_config_data() + curated_data = self._load_config_data() + merged_data = self._merge_manifest_data(live_data, curated_data) + + self._extras = {} + configs = [config for config in self._parse_models(merged_data) if config is not None] + self._build_maps(configs) + + def _load_live_config_data(self) -> dict: + if self._live_resource is not None: + try: + if hasattr(self._live_resource, "read_text"): + config_text = self._live_resource.read_text(encoding="utf-8") + else: + with self._live_resource.open("r", encoding="utf-8") as handle: + config_text = handle.read() + data = json.loads(config_text) + except FileNotFoundError: + logger.debug("Packaged %s not found", self.LIVE_DEFAULT_FILENAME) + return {"models": []} + except Exception as exc: + logger.warning("Failed to read packaged %s: %s", self.LIVE_DEFAULT_FILENAME, exc) + return {"models": []} + return data or {"models": []} + + if not self._live_config_path: + return {"models": []} + + if not self._live_config_path.exists(): + logger.debug("Zen live registry config not found at %s", self._live_config_path) + return {"models": []} + + data = read_json_file(str(self._live_config_path)) + return data or {"models": []} + + @staticmethod + def _merge_manifest_data(live_data: dict, curated_data: dict) -> dict: + merged_models: dict[str, dict] = {} + + for source in (live_data, curated_data): + for raw in source.get("models", []): + if not isinstance(raw, dict): + continue + + model_name = raw.get("model_name") + if not model_name: + continue + + existing = merged_models.get(model_name, {}) + merged_models[model_name] = {**existing, **dict(raw)} + + return {"models": list(merged_models.values())} + def _finalise_entry(self, entry: dict) -> tuple[ModelCapabilities, dict]: provider_override = entry.get("provider") if isinstance(provider_override, str): diff --git a/scripts/sync_zen_models.py b/scripts/sync_zen_models.py new file mode 100644 index 0000000..8637787 --- /dev/null +++ b/scripts/sync_zen_models.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from utils.env import get_env +from utils.file_utils import read_json_file + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ROOT / "conf" / "zen_models_live.json" +DEFAULT_CURATED = ROOT / "conf" / "zen_models.json" +ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models" + + +def fetch_zen_models(url: str, api_key: str) -> dict: + request = Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "pal-mcp-server/zen-model-sync", + }, + ) + + with urlopen(request, timeout=30) as response: + charset = response.headers.get_content_charset("utf-8") + payload = response.read().decode(charset) + + data = json.loads(payload) + if not isinstance(data, dict): + raise ValueError("Zen models payload must be a JSON object") + return data + + +def load_curated_models(path: Path) -> dict[str, dict]: + if not path.exists(): + return {} + + data = read_json_file(str(path)) or {} + models = data.get("models", []) + if not isinstance(models, list): + return {} + + curated: dict[str, dict] = {} + for item in models: + if not isinstance(item, dict): + continue + model_name = item.get("model_name") + if isinstance(model_name, str) and model_name: + curated[model_name] = dict(item) + return curated + + +def _infer_defaults_from_model_name(model_name: str) -> dict: + lower_name = model_name.lower() + + defaults = { + "aliases": [], + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": False, + "supports_json_mode": True, + "supports_function_calling": True, + "supports_images": False, + "max_image_size_mb": 0.0, + "supports_temperature": True, + "temperature_constraint": "range", + "description": f"OpenCode Zen live model: {model_name}", + "intelligence_score": 10, + "allow_code_generation": False, + } + + if lower_name.startswith("claude-"): + defaults.update( + { + "supports_extended_thinking": True, + "supports_images": True, + "max_image_size_mb": 20.0, + } + ) + if "-4-6" in lower_name: + defaults.update({"context_window": 1000000, "max_output_tokens": 128000}) + elif lower_name.startswith("gemini-"): + defaults.update( + { + "context_window": 1048576, + "max_output_tokens": 65536, + "supports_extended_thinking": True, + "supports_images": True, + "max_image_size_mb": 20.0, + } + ) + elif lower_name.startswith("gpt-"): + defaults.update( + { + "context_window": 400000, + "max_output_tokens": 128000, + "supports_extended_thinking": True, + "supports_images": True, + "max_image_size_mb": 20.0, + } + ) + if "5.4" in lower_name: + defaults["context_window"] = 1050000 if "-pro" in lower_name or lower_name == "gpt-5.4" else 400000 + if lower_name in { + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5", + "gpt-5-codex", + }: + defaults["use_openai_response_api"] = True + + return defaults + + +def convert_model(model_data: dict, curated_models: dict[str, dict]) -> dict | None: + model_name = model_data.get("id") + if not isinstance(model_name, str) or not model_name: + return None + + curated_entry = curated_models.get(model_name, {}) + defaults = _infer_defaults_from_model_name(model_name) + + return { + "model_name": model_name, + "aliases": [], + "context_window": int(curated_entry.get("context_window", defaults["context_window"])), + "max_output_tokens": int(curated_entry.get("max_output_tokens", defaults["max_output_tokens"])), + "supports_extended_thinking": bool( + curated_entry.get("supports_extended_thinking", defaults["supports_extended_thinking"]) + ), + "supports_json_mode": bool(curated_entry.get("supports_json_mode", defaults["supports_json_mode"])), + "supports_function_calling": bool( + curated_entry.get("supports_function_calling", defaults["supports_function_calling"]) + ), + "supports_images": bool(curated_entry.get("supports_images", defaults["supports_images"])), + "max_image_size_mb": float(curated_entry.get("max_image_size_mb", defaults["max_image_size_mb"])), + "supports_temperature": bool(curated_entry.get("supports_temperature", defaults["supports_temperature"])), + "temperature_constraint": curated_entry.get("temperature_constraint", defaults["temperature_constraint"]), + "description": curated_entry.get("description") + or f"Generated baseline metadata for OpenCode Zen model {model_name}.", + "intelligence_score": int(curated_entry.get("intelligence_score", defaults["intelligence_score"])), + "allow_code_generation": bool(curated_entry.get("allow_code_generation", defaults["allow_code_generation"])), + **( + {"use_openai_response_api": bool(curated_entry.get("use_openai_response_api", True))} + if curated_entry.get("use_openai_response_api") is not None or defaults.get("use_openai_response_api") + else {} + ), + } + + +def build_output_document(source: dict, source_url: str, curated_models: dict[str, dict]) -> dict: + models = [] + for model_data in source.get("data", []): + if not isinstance(model_data, dict): + continue + + converted = convert_model(model_data, curated_models) + if converted: + models.append(converted) + + models.sort(key=lambda item: item["model_name"]) + + return { + "_README": { + "description": "Generated baseline OpenCode Zen catalogue for PAL MCP Server.", + "source": source_url, + "usage": "Generated by scripts/sync_zen_models.py. Curated overrides belong in conf/zen_models.json.", + "field_notes": "Entries are conservative discovery data. Curated manifest values override these at runtime.", + }, + "models": models, + } + + +def write_output(path: Path, document: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(document, handle, indent=2, ensure_ascii=False) + handle.write("\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync OpenCode Zen live model catalogue into PAL config.") + parser.add_argument("--url", default=ZEN_MODELS_URL, help="Zen models endpoint") + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + help="Path to the generated live Zen manifest", + ) + parser.add_argument( + "--curated", + default=str(DEFAULT_CURATED), + help="Path to the curated Zen manifest used for metadata enrichment", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + output_path = Path(args.output) + curated_path = Path(args.curated) + api_key = get_env("ZEN_API_KEY") + + if not api_key: + print("Failed to sync Zen models: ZEN_API_KEY is not set", file=sys.stderr) + return 1 + + try: + curated_models = load_curated_models(curated_path) + source = fetch_zen_models(args.url, api_key) + document = build_output_document(source, args.url, curated_models) + write_output(output_path, document) + except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: + print(f"Failed to sync Zen models: {exc}", file=sys.stderr) + return 1 + + print(f"Wrote {len(document['models'])} Zen models to {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sync_zen_models.py b/tests/test_sync_zen_models.py new file mode 100644 index 0000000..0ff6216 --- /dev/null +++ b/tests/test_sync_zen_models.py @@ -0,0 +1,61 @@ +import importlib.util +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "sync_zen_models.py" +SPEC = importlib.util.spec_from_file_location("sync_zen_models", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_convert_model_applies_family_defaults_for_gpt_5_4(): + converted = MODULE.convert_model({"id": "gpt-5.4"}, curated_models={}) + + assert converted is not None + assert converted["model_name"] == "gpt-5.4" + assert converted["context_window"] == 1050000 + assert converted["max_output_tokens"] == 128000 + assert converted["supports_extended_thinking"] is True + assert converted["supports_images"] is True + assert converted["use_openai_response_api"] is True + + +def test_convert_model_prefers_curated_metadata_when_available(): + converted = MODULE.convert_model( + {"id": "claude-opus-4-5"}, + curated_models={ + "claude-opus-4-5": { + "context_window": 200000, + "max_output_tokens": 64000, + "supports_extended_thinking": False, + "description": "Curated Opus 4.5", + "intelligence_score": 18, + "allow_code_generation": True, + } + }, + ) + + assert converted is not None + assert converted["model_name"] == "claude-opus-4-5" + assert converted["context_window"] == 200000 + assert converted["max_output_tokens"] == 64000 + assert converted["supports_extended_thinking"] is False + assert converted["description"] == "Curated Opus 4.5" + assert converted["allow_code_generation"] is True + + +def test_build_output_document_sorts_model_ids(): + document = MODULE.build_output_document( + { + "data": [ + {"id": "gpt-5.4-pro"}, + {"id": "claude-opus-4-6"}, + ] + }, + "https://opencode.ai/zen/v1/models", + curated_models={}, + ) + + model_names = [item["model_name"] for item in document["models"]] + assert model_names == sorted(model_names) diff --git a/tests/test_zen_model_registry.py b/tests/test_zen_model_registry.py index b8bdaa4..1314a30 100644 --- a/tests/test_zen_model_registry.py +++ b/tests/test_zen_model_registry.py @@ -3,6 +3,7 @@ import json import os import tempfile +from pathlib import Path from unittest.mock import patch from providers.registries.zen import ZenModelRegistry @@ -43,14 +44,19 @@ class TestZenModelRegistry: json.dump(config_data, f) temp_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as live_file: + json.dump({"models": []}, live_file) + live_path = live_file.name + try: - registry = ZenModelRegistry(config_path=temp_path) + registry = ZenModelRegistry(config_path=temp_path, live_config_path=live_path) assert len(registry.list_models()) == 1 assert "test/zen-model-1" in registry.list_models() assert "zen-test1" in registry.list_aliases() assert "zt1" in registry.list_aliases() finally: os.unlink(temp_path) + os.unlink(live_path) def test_get_capabilities(self): """Test capability retrieval.""" @@ -158,9 +164,172 @@ class TestZenModelRegistry: json.dump(empty_config, f) temp_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as live_file: + json.dump({"models": []}, live_file) + live_path = live_file.name + try: - registry = ZenModelRegistry(config_path=temp_path) + registry = ZenModelRegistry(config_path=temp_path, live_config_path=live_path) assert len(registry.list_models()) == 0 assert len(registry.list_aliases()) == 0 finally: os.unlink(temp_path) + os.unlink(live_path) + + def test_live_catalogue_adds_unsynced_model_ids(self): + curated_data = { + "models": [ + { + "model_name": "gpt-5.1", + "aliases": ["zen-gpt5.1"], + "context_window": 400000, + "max_output_tokens": 64000, + "intelligence_score": 16, + } + ] + } + live_data = { + "models": [ + { + "model_name": "gpt-5.4", + "aliases": [], + "context_window": 1050000, + "max_output_tokens": 128000, + "supports_extended_thinking": True, + "supports_json_mode": True, + "supports_function_calling": True, + "supports_images": True, + "max_image_size_mb": 20.0, + "supports_temperature": True, + "temperature_constraint": "range", + "description": "Live-only GPT-5.4 entry", + "use_openai_response_api": True, + } + ] + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as curated_file: + json.dump(curated_data, curated_file) + curated_path = curated_file.name + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as live_file: + json.dump(live_data, live_file) + live_path = live_file.name + + try: + registry = ZenModelRegistry(config_path=curated_path, live_config_path=live_path) + assert "gpt-5.4" in registry.list_models() + caps = registry.resolve("gpt-5.4") + assert caps is not None + assert caps.description == "Live-only GPT-5.4 entry" + assert caps.use_openai_response_api is True + finally: + os.unlink(curated_path) + os.unlink(live_path) + + def test_curated_manifest_overrides_live_metadata(self): + curated_data = { + "models": [ + { + "model_name": "gpt-5.4", + "aliases": ["zen-gpt5.4"], + "context_window": 1050000, + "max_output_tokens": 128000, + "supports_extended_thinking": True, + "supports_json_mode": True, + "supports_function_calling": True, + "supports_images": True, + "max_image_size_mb": 20.0, + "supports_temperature": False, + "temperature_constraint": "fixed", + "description": "Curated override", + "intelligence_score": 19, + "allow_code_generation": True, + "use_openai_response_api": True, + } + ] + } + live_data = { + "models": [ + { + "model_name": "gpt-5.4", + "aliases": [], + "context_window": 400000, + "max_output_tokens": 64000, + "supports_extended_thinking": False, + "supports_json_mode": True, + "supports_function_calling": True, + "supports_images": False, + "max_image_size_mb": 0.0, + "supports_temperature": True, + "temperature_constraint": "range", + "description": "Live baseline", + "intelligence_score": 10, + "allow_code_generation": False, + } + ] + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as curated_file: + json.dump(curated_data, curated_file) + curated_path = curated_file.name + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as live_file: + json.dump(live_data, live_file) + live_path = live_file.name + + try: + registry = ZenModelRegistry(config_path=curated_path, live_config_path=live_path) + caps = registry.resolve("zen-gpt5.4") + assert caps is not None + assert caps.model_name == "gpt-5.4" + assert caps.description == "Curated override" + assert caps.context_window == 1050000 + assert caps.max_output_tokens == 128000 + assert caps.supports_images is True + assert caps.supports_temperature is False + assert caps.allow_code_generation is True + assert caps.use_openai_response_api is True + finally: + os.unlink(curated_path) + os.unlink(live_path) + + def test_missing_live_catalogue_keeps_curated_models_working(self, monkeypatch): + missing_live_path = Path(tempfile.gettempdir()) / "pal-missing-zen-live.json" + if missing_live_path.exists(): + missing_live_path.unlink() + + monkeypatch.setenv("ZEN_LIVE_MODELS_CONFIG_PATH", str(missing_live_path)) + + registry = ZenModelRegistry() + assert "gpt-5.1" in registry.list_models() + assert registry.resolve("zen-gpt5.1") is not None + + def test_invalid_live_json_keeps_curated_models_working(self): + curated_data = { + "models": [ + { + "model_name": "gpt-5.1", + "aliases": ["zen-gpt5.1"], + "context_window": 400000, + "max_output_tokens": 64000, + "intelligence_score": 16, + } + ] + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as curated_file: + json.dump(curated_data, curated_file) + curated_path = curated_file.name + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as live_file: + live_file.write("{ invalid json }") + live_path = live_file.name + + try: + registry = ZenModelRegistry(config_path=curated_path, live_config_path=live_path) + assert "gpt-5.1" in registry.list_models() + assert registry.resolve("zen-gpt5.1") is not None + finally: + os.unlink(curated_path) + os.unlink(live_path)