"""LLM provider presets used by Settings and the runtime AI provider bridge.""" from __future__ import annotations from typing import Any import httpx MODELS_DEV_URL = "https://models.dev/api.json" OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models" class LLMProviderCatalogError(RuntimeError): """The upstream catalog cannot supply a usable model list.""" OPENCODE_GO_MODEL_PROVIDER_APIS = { "minimax-m2.7": "anthropic-messages", "minimax-m2.5": "anthropic-messages", } OPENCODE_GO_FALLBACK_MODELS = [ "minimax-m2.7", "minimax-m2.5", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus", "qwen3.5-plus", "mimo-v2.5-pro", "mimo-v2.5", ] FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "minimax": { "provider": "minimax", "label": "MiniMax", "provider_api": "anthropic-messages", "base_url": "https://api.minimaxi.com/anthropic", "model": "MiniMax-M2.7", "models": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2"], "api_key_env": "MINIMAX_API_KEY", "source": "fallback", }, "openai": { "provider": "openai", "label": "OpenAI", "provider_api": "openai-completions", "base_url": "https://api.openai.com/v1", "model": "gpt-5.1", "models": ["gpt-5.1", "gpt-5.1-codex", "gpt-4.1", "gpt-4o"], "api_key_env": "OPENAI_API_KEY", "source": "fallback", }, "anthropic": { "provider": "anthropic", "label": "Anthropic", "provider_api": "anthropic-messages", "base_url": "https://api.anthropic.com/v1", "model": "claude-sonnet-4-6", "models": ["claude-sonnet-4-6", "claude-opus-4-5", "claude-3-5-haiku-20241022"], "api_key_env": "ANTHROPIC_API_KEY", "source": "fallback", }, "deepseek": { "provider": "deepseek", "label": "DeepSeek", "provider_api": "openai-completions", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat", "models": ["deepseek-chat", "deepseek-reasoner"], "api_key_env": "DEEPSEEK_API_KEY", "source": "fallback", }, "alibaba": { "provider": "alibaba", "label": "Alibaba Qwen / DashScope", "provider_api": "openai-completions", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model": "qwen3-max", "models": ["qwen3-max", "qwen3.5-plus", "qwen-max", "qwen-plus"], "api_key_env": "DASHSCOPE_API_KEY", "source": "fallback", }, "moonshotai": { "provider": "moonshotai", "label": "Moonshot AI / Kimi", "provider_api": "openai-completions", "base_url": "https://api.moonshot.ai/v1", "model": "kimi-k2.5", "models": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "api_key_env": "MOONSHOT_API_KEY", "source": "fallback", }, "openrouter": { "provider": "openrouter", "label": "OpenRouter", "provider_api": "openai-completions", "base_url": "https://openrouter.ai/api/v1", "model": "openai/gpt-5.1", "models": ["openai/gpt-5.1", "anthropic/claude-sonnet-4.5", "qwen/qwen3-max"], "api_key_env": "OPENROUTER_API_KEY", "source": "fallback", }, "opencode-go": { "provider": "opencode-go", "label": "OpenCode Go", "provider_api": "openai-completions", "base_url": "https://opencode.ai/zen/go/v1", "model": "glm-5.1", "models": OPENCODE_GO_FALLBACK_MODELS, "model_provider_apis": OPENCODE_GO_MODEL_PROVIDER_APIS, "api_key_env": "OPENCODE_GO_API_KEY", "source": "fallback", }, "ollama": { "provider": "ollama", "label": "Ollama Local", "provider_api": "ollama-generate", "base_url": "http://127.0.0.1:11434", "model": "qwen2.5:7b", "models": ["qwen2.5:7b", "llama3.1:8b", "mistral:7b"], "api_key_env": "", "source": "fallback", }, } MODELS_DEV_PROVIDER_KEYS = { "minimax": "minimax", "openai": "openai", "anthropic": "anthropic", "deepseek": "deepseek", "alibaba": "alibaba", "moonshotai": "moonshotai", "openrouter": "openrouter", } def list_fallback_llm_provider_presets() -> list[dict[str, Any]]: return [dict(value) for value in FALLBACK_LLM_PROVIDER_PRESETS.values()] def get_fallback_llm_provider_preset(provider: str) -> dict[str, Any]: key = provider.strip().lower() if key not in FALLBACK_LLM_PROVIDER_PRESETS: raise ValueError(f"Unsupported LLM provider preset: {provider}") return dict(FALLBACK_LLM_PROVIDER_PRESETS[key]) def _opencode_go_model_provider_apis(model_ids: list[str]) -> dict[str, str]: return { model_id: OPENCODE_GO_MODEL_PROVIDER_APIS.get(model_id, "openai-completions") for model_id in model_ids } async def refresh_llm_provider_preset(provider: str, api_key: str | None = None) -> dict[str, Any]: fallback = get_fallback_llm_provider_preset(provider) if fallback["provider"] == "opencode-go": headers = {"User-Agent": "Planet/1.0"} if api_key: headers["Authorization"] = f"Bearer {api_key}" async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: response = await client.get( OPENCODE_GO_MODELS_URL, headers=headers, ) response.raise_for_status() payload = response.json() data = payload.get("data") if isinstance(payload, dict) else [] model_ids = [ str(item.get("id")) for item in data if isinstance(item, dict) and item.get("id") ] if not model_ids: raise LLMProviderCatalogError("The provider returned an empty model catalog") return { **fallback, "model": fallback["model"] if fallback["model"] in model_ids else model_ids[0], "models": model_ids, "model_provider_apis": _opencode_go_model_provider_apis(model_ids), "source": OPENCODE_GO_MODELS_URL, } models_dev_key = MODELS_DEV_PROVIDER_KEYS.get(fallback["provider"]) if not models_dev_key: raise LLMProviderCatalogError("Live catalog refresh is unavailable for this provider") async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: response = await client.get( MODELS_DEV_URL, headers={"User-Agent": "Planet/1.0"}, ) response.raise_for_status() catalog = response.json() upstream = catalog.get(models_dev_key) if isinstance(catalog, dict) else None if not isinstance(upstream, dict): raise LLMProviderCatalogError("The provider is missing from the model catalog") upstream_models = upstream.get("models") if isinstance(upstream.get("models"), dict) else {} # Catalog insertion order is not release order; old entries can appear first. model_ids = sorted( ( model_id for model_id, model in upstream_models.items() if model_id and isinstance(model, dict) ), key=lambda model_id: (str(upstream_models[model_id].get("release_date") or ""), model_id), reverse=True, ) if not model_ids: raise LLMProviderCatalogError("The provider returned an empty model catalog") base_url = upstream.get("api") or fallback["base_url"] if fallback["provider"] == "deepseek" and base_url == "https://api.deepseek.com": base_url = "https://api.deepseek.com/v1" refreshed = { **fallback, "label": upstream.get("name") or fallback["label"], "base_url": base_url, "model": model_ids[0], "models": model_ids, "api_key_env": (upstream.get("env") or [fallback["api_key_env"]])[0], "source": MODELS_DEV_URL, } return refreshed