Compare commits

..

2 Commits

Author SHA1 Message Date
Torbjørn Lindahl
1c9f26d6c8 test(zen): align provider tests with API
Some checks failed
PR Docker Build / Build Docker Image (pull_request) Has been cancelled
Semantic PR / Validate PR (pull_request) Has been cancelled
Tests / test (3.10) (pull_request) Has been cancelled
Tests / test (3.11) (pull_request) Has been cancelled
Tests / test (3.12) (pull_request) Has been cancelled
Tests / lint (pull_request) Has been cancelled
2026-04-01 23:59:38 +02:00
Torbjørn Lindahl
7ef476cfbd feat(zen): add live model sync 2026-04-01 23:48:16 +02:00
8 changed files with 1335 additions and 54 deletions

696
conf/zen_models_live.json Normal file
View File

@@ -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
}
]
}

View File

@@ -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/openai_models.json` OpenAI catalogue (can be overridden with `OPENAI_MODELS_CONFIG_PATH`)
- `conf/gemini_models.json` Gemini catalogue (`GEMINI_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/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.json` Curated OpenRouter overrides (`OPENROUTER_MODELS_CONFIG_PATH`)
- `conf/openrouter_models_live.json` Generated live OpenRouter catalogue (`OPENROUTER_LIVE_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`) - `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` | | 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` | | 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` | | 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` | | 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 | | 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. > **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.

View File

@@ -221,13 +221,15 @@ CUSTOM_MODEL_NAME=your-loaded-model
The system automatically routes models to the appropriate provider: 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`) 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`) 2. Entries in `conf/zen_models_live.json` and `conf/zen_models.json` → Routed through OpenCode Zen (requires `ZEN_API_KEY`)
3. **Unknown models** → Fallback logic based on model name patterns 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:** **Provider Priority Order:**
1. Native APIs (Google, OpenAI) - if API keys are available 1. Native APIs (Google, OpenAI) - if API keys are available
2. Custom endpoints - for models declared in `conf/custom_models.json` 2. OpenCode Zen - curated gateway models
3. OpenRouter - catch-all for cloud 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. 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 ### 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 ### Refreshing the Live OpenRouter Catalogue

View File

@@ -2,14 +2,48 @@
from __future__ import annotations 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 ..shared import ModelCapabilities, ProviderType
from .base import CAPABILITY_FIELD_NAMES, CapabilityModelRegistry from .base import CAPABILITY_FIELD_NAMES, CapabilityModelRegistry
logger = logging.getLogger(__name__)
class ZenModelRegistry(CapabilityModelRegistry): class ZenModelRegistry(CapabilityModelRegistry):
"""Capability registry backed by ``conf/zen_models.json``.""" """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__( super().__init__(
env_var_name="ZEN_MODELS_CONFIG_PATH", env_var_name="ZEN_MODELS_CONFIG_PATH",
default_filename="zen_models.json", default_filename="zen_models.json",
@@ -18,6 +52,60 @@ class ZenModelRegistry(CapabilityModelRegistry):
config_path=config_path, 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]: def _finalise_entry(self, entry: dict) -> tuple[ModelCapabilities, dict]:
provider_override = entry.get("provider") provider_override = entry.get("provider")
if isinstance(provider_override, str): if isinstance(provider_override, str):

237
scripts/sync_zen_models.py Normal file
View File

@@ -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())

View File

@@ -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)

View File

@@ -3,6 +3,7 @@
import json import json
import os import os
import tempfile import tempfile
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from providers.registries.zen import ZenModelRegistry from providers.registries.zen import ZenModelRegistry
@@ -43,14 +44,19 @@ class TestZenModelRegistry:
json.dump(config_data, f) json.dump(config_data, f)
temp_path = f.name 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: 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 len(registry.list_models()) == 1
assert "test/zen-model-1" in registry.list_models() assert "test/zen-model-1" in registry.list_models()
assert "zen-test1" in registry.list_aliases() assert "zen-test1" in registry.list_aliases()
assert "zt1" in registry.list_aliases() assert "zt1" in registry.list_aliases()
finally: finally:
os.unlink(temp_path) os.unlink(temp_path)
os.unlink(live_path)
def test_get_capabilities(self): def test_get_capabilities(self):
"""Test capability retrieval.""" """Test capability retrieval."""
@@ -158,9 +164,172 @@ class TestZenModelRegistry:
json.dump(empty_config, f) json.dump(empty_config, f)
temp_path = f.name 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: 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_models()) == 0
assert len(registry.list_aliases()) == 0 assert len(registry.list_aliases()) == 0
finally: finally:
os.unlink(temp_path) 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)

View File

@@ -288,57 +288,52 @@ class TestZenAPIMocking:
mock_response.usage.prompt_tokens = 10 mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 20 mock_response.usage.completion_tokens = 20
with patch.object(provider.client.chat.completions, "create", return_value=mock_response): with patch.object(provider.client.chat.completions, "create", return_value=mock_response) as create_mock:
# Test the completion method - this will initialize the client response = provider.generate_content(
response = provider.complete( prompt="Hello",
model="claude-sonnet-4-5", messages=[{"role": "user", "content": "Hello"}], temperature=0.7 model_name="claude-sonnet-4-5",
temperature=0.7,
) )
assert response.content == "Mocked response from Zen" assert response.content == "Mocked response from Zen"
call_kwargs = create_mock.call_args.kwargs
assert call_kwargs["model"] == "claude-sonnet-4-5"
assert call_kwargs["messages"] == [{"role": "user", "content": "Hello"}]
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["stream"] is False
def test_streaming_completion_mock(self): def test_generate_content_resolves_aliases(self):
"""Test streaming completion with mocked API.""" """Test generate_content resolves Zen aliases before calling the API."""
provider = ZenProvider(api_key="test-key") provider = ZenProvider(api_key="test-key")
# Mock streaming response mock_response = Mock()
mock_chunk1 = Mock() mock_response.output_text = "Alias response"
mock_chunk1.choices = [Mock()] mock_response.model = "gpt-5.1-codex"
mock_chunk1.choices[0].delta.content = "Hello" mock_response.id = "resp_123"
mock_chunk1.choices[0].finish_reason = None mock_response.created_at = 1234567890
mock_response.usage = Mock()
mock_response.usage.prompt_tokens = 11
mock_response.usage.completion_tokens = 7
mock_response.usage.total_tokens = 18
mock_chunk2 = Mock() with patch.object(provider.client.responses, "create", return_value=mock_response) as create_mock:
mock_chunk2.choices = [Mock()] response = provider.generate_content(
mock_chunk2.choices[0].delta.content = " world!" prompt="Say hello",
mock_chunk2.choices[0].finish_reason = "stop" model_name="zen-codex",
mock_stream = [mock_chunk1, mock_chunk2]
# Access client to initialize it first
_ = provider.client
with patch.object(provider.client.chat.completions, "create", return_value=mock_stream):
# Test streaming completion
stream = provider.complete_stream(
model="gpt-5.1-codex",
messages=[{"role": "user", "content": "Say hello"}],
) )
chunks = list(stream) assert response.content == "Alias response"
assert len(chunks) == 2 call_kwargs = create_mock.call_args.kwargs
assert chunks[0].content == "Hello" assert call_kwargs["model"] == "gpt-5.1-codex"
assert chunks[1].content == " world!" assert call_kwargs["input"] == [{"role": "user", "content": [{"type": "input_text", "text": "Say hello"}]}]
def test_api_error_handling(self): def test_api_error_handling(self):
"""Test error handling for API failures.""" """Test error handling for API failures."""
provider = ZenProvider(api_key="test-key") provider = ZenProvider(api_key="test-key")
# Mock API error with patch.object(provider.client.chat.completions, "create", side_effect=RuntimeError("Mock API error")):
from openai import APIError with pytest.raises(RuntimeError, match="OpenCode Zen API error for model claude-sonnet-4-5"):
provider.generate_content(prompt="Test", model_name="claude-sonnet-4-5")
api_error = APIError("Mock API error", request=Mock(), body="error details")
with patch.object(provider._client.chat.completions, "create", side_effect=api_error):
with pytest.raises(APIError):
provider.complete(model="claude-sonnet-4-5", messages=[{"role": "user", "content": "Test"}])
def test_invalid_model_error(self): def test_invalid_model_error(self):
"""Test error handling for invalid models.""" """Test error handling for invalid models."""
@@ -351,11 +346,6 @@ class TestZenAPIMocking:
"""Test handling of authentication errors.""" """Test handling of authentication errors."""
provider = ZenProvider(api_key="invalid-key") provider = ZenProvider(api_key="invalid-key")
# Mock authentication error with patch.object(provider.client.chat.completions, "create", side_effect=RuntimeError("auth failed")):
from openai import AuthenticationError with pytest.raises(RuntimeError, match="OpenCode Zen API error for model claude-sonnet-4-5"):
provider.generate_content(prompt="Test", model_name="claude-sonnet-4-5")
auth_error = AuthenticationError("Invalid API key", request=Mock(), body="auth failed")
with patch.object(provider._client.chat.completions, "create", side_effect=auth_error):
with pytest.raises(AuthenticationError):
provider.complete(model="claude-sonnet-4-5", messages=[{"role": "user", "content": "Test"}])