#!/usr/bin/env python3 """ Auto-discover models from 9Router and update opencode.json. Usage: python3 discover_models.py [--config PATH] [--output PATH] [--dry-run] [--push] Scans /v1/models on the 9router endpoint, filters to text-capable models, and generates the provider.models section for opencode.json. Environment variables: NINEROUTER_BASE_URL - 9Router base URL (default: https://ollama.wilddragon.net/v1) NINEROUTER_API_KEY - 9Router API key FORGEJO_TOKEN - Forgejo API token (for --push) """ import json import sys import os import argparse import urllib.request import urllib.error import re # 9Router endpoint config BASE_URL = os.environ.get("NINEROUTER_BASE_URL", "https://ollama.wilddragon.net/v1") API_KEY = os.environ.get("NINEROUTER_API_KEY", "") CONFIG_PATH = os.path.expanduser("~/.config/opencode/opencode.json") # Forgejo FORGEJO_URL = "https://forge.wilddragon.net" FORGEJO_OWNER = "zgaetano" FORGEJO_REPO = "opencode-patches" FORGEJO_TOKEN = os.environ.get("FORGEJO_TOKEN", "") # Model IDs to skip (non-text, ASR, image, etc.) SKIP_PATTERNS = [ "asr", "tts", "image", "embed", "vision-preview-only", "parakeet", "whisper", "speech", "audio", "-review", ] # Provider prefixes in 9router model IDs -> display names PROVIDER_NAMES = { "cc": "CC", "openrouter": "OpenRouter", "nvidia": "Nvidia", "ollama": "Ollama Cloud", "ollama-local": "Ollama Local", "gemini": "Google Gemini", "ds": "DeepSeek", "cx": "Codex", "mistral": "Mistral", "jinx": "Jinx", "ag": "Antigravity", "cl": "Cloudliner", "kr": "KR Cloud", "cf": "Cloudflare", "g0i": "G0I Cloud", "oc": "OC Cloud", } # Known model name mappings for clean display MODEL_NAME_MAP = { "claude-opus-4-7": "Claude Opus 4.7", "claude-opus-4-6": "Claude Opus 4.6", "claude-opus-4-5-20251101": "Claude Opus 4.5", "claude-opus-4-8": "Claude Opus 4.8", "claude-sonnet-4-6": "Claude Sonnet 4.6", "claude-sonnet-4-5-20250929": "Claude Sonnet 4.5", "claude-sonnet-4-5": "Claude Sonnet 4.5", "claude-sonnet-4": "Claude Sonnet 4", "claude-haiku-4-5-20251001": "Claude Haiku 4.5", "claude-haiku-4-5": "Claude Haiku 4.5", "claude-opus-4-6-thinking": "Claude Opus 4.6 Thinking", "claude-opus-4.7": "Claude Opus 4.7", "claude-sonnet-4.6": "Claude Sonnet 4.6", "claude-opus-4.6": "Claude Opus 4.6", "gpt-5.5": "GPT-5.5", "gpt-5.4": "GPT-5.4", "gpt-5.3-codex": "GPT-5.3 Codex", "gpt-oss-120b": "GPT OSS 120B", "gpt-oss-120b-medium": "GPT OSS 120B Medium", "gpt-oss:120b": "GPT OSS 120B", "gpt-image-2": "GPT Image 2", "gemini-3.1-pro-preview": "Gemini 3.1 Pro Preview", "gemini-3.1-flash-lite-preview": "Gemini 3.1 Flash Lite Preview", "gemini-3-flash-preview": "Gemini 3 Flash Preview", "gemini-2.0-flash-lite": "Gemini 2.0 Flash Lite", "gemini-2.5-pro": "Gemini 2.5 Pro", "gemini-2.5-flash": "Gemini 2.5 Flash", "gemini-2.5-flash-lite": "Gemini 2.5 Flash Lite", "gemini-3.1-pro-high": "Gemini 3.1 Pro High", "gemini-3.1-pro-low": "Gemini 3.1 Pro Low", "gemini-3-flash": "Gemini 3 Flash", "deepseek-v4-pro": "DeepSeek V4 Pro", "deepseek-v4-pro-max": "DeepSeek V4 Pro Max", "deepseek-v4-pro-none": "DeepSeek V4 Pro None", "deepseek-v4-flash": "DeepSeek V4 Flash", "deepseek-chat": "DeepSeek Chat", "deepseek-reasoner": "DeepSeek Reasoner", "deepseek-3.2": "DeepSeek 3.2", "deepseek-r1-14b": "DeepSeek R1 14B", "deepseek-r1-distill-qwen-32b": "DeepSeek R1 Distill Qwen 32B", "qwen3.5": "Qwen 3.5", "qwen3-coder-80b": "Qwen 3 Coder 80B", "qwen3-coder-next": "Qwen 3 Coder Next", "qwen3-coder-next-thinking": "Qwen 3 Coder Next (Thinking)", "qwen3-coder-next-agentic": "Qwen 3 Coder Next (Agentic)", "qwen3-coder-next-thinking-agentic": "Qwen 3 Coder Next (Thinking+Agentic)", "qwen2.5-coder-32b-instruct": "Qwen 2.5 Coder 32B", "qwen2.5-coder:14b": "Qwen 2.5 Coder 14B", "qwq-32b": "QwQ 32B", "glm-5": "GLM 5", "glm-5.1": "GLM 5.1", "glm-4.7-flash": "GLM 4.7 Flash", "kimi-k2.5": "Kimi K2.5", "kimi-k2.6": "Kimi K2.6", "mistral-large-latest": "Mistral Large", "mistral-medium-latest": "Mistral Medium", "mistral-small-3.1-24b-instruct": "Mistral Small 3.1 24B", "codestral-latest": "Codestral", "llama-3.2-1b-instruct": "Llama 3.2 1B", "llama-3.2-3b-instruct": "Llama 3.2 3B", "llama-3.1-8b-instruct-fp8-fast": "Llama 3.1 8B (FP8 Fast)", "llama-3.1-8b-instruct-awq": "Llama 3.1 8B (AWQ)", "llama-3.1-70b-instruct-fp8-fast": "Llama 3.1 70B (FP8 Fast)", "llama-3.3-70b-instruct-fp8-fast": "Llama 3.3 70B (FP8 Fast)", "nemotron-3-nano-30b-a3b:free": "Nemotron 3 Nano 30B (Free)", "nemotron-3-nano-omni-30b-a3b-reasoning:free": "Nemotron 3 Nano Omni Reasoning (Free)", "nemotron-3-super-120b-a12b:free": "Nemotron 3 Super 120B (Free)", "trinity-large-thinking:free": "Trinity Large Thinking (Free)", "gemma-4-26b-a4b-it:free": "Gemma 4 26B IT (Free)", "gemma-4-31b-it": "Gemma 4 31B IT", "kiro-claude-sonnet-4-5": "Kiro Claude Sonnet 4.5", "kiro-claude-sonnet-4-5-agentic": "Kiro Claude Sonnet 4.5 (Agentic)", "kiro-claude-sonnet-4": "Kiro Claude Sonnet 4", "kiro-claude-haiku-4-5": "Kiro Claude Haiku 4.5", "kiro-claude-haiku-4-5-agentic": "Kiro Claude Haiku 4.5 (Agentic)", "kiro-auto": "Kiro Auto", "kiro-glm-5": "Kiro GLM 5", "kiro-glm-5-agentic": "Kiro GLM 5 (Agentic)", "kiro-minimax-m2-1": "Kiro MiniMax M2.1", "kiro-minimax-m2-1-agentic": "Kiro MiniMax M2.1 (Agentic)", "kiro-minimax-m2-5": "Kiro MiniMax M2.5", "kiro-minimax-m2-5-agentic": "Kiro MiniMax M2.5 (Agentic)", "kiro-deepseek-3-2": "Kiro DeepSeek 3.2", "kiro-deepseek-3-2-agentic": "Kiro DeepSeek 3.2 (Agentic)", "kiro-qwen3-coder-next-agentic": "Kiro Qwen 3 Coder Next (Agentic)", "minimax-m2.5": "MiniMax M2.5", "minimax-m2.1": "MiniMax M2.1", "minimax-m2.5-thinking": "MiniMax M2.5 (Thinking)", "minimax-m2.5-agentic": "MiniMax M2.5 (Agentic)", "minimax-m2.5-thinking-agentic": "MiniMax M2.5 (Thinking+Agentic)", "minimax-m2.1-thinking": "MiniMax M2.1 (Thinking)", "minimax-m2.1-agentic": "MiniMax M2.1 (Agentic)", "minimax-m2.1-thinking-agentic": "MiniMax M2.1 (Thinking+Agentic)", "claudecode": "ClaudeCode", "kat-coder-pro": "Kat Coder Pro", "owl-alpha": "Owl Alpha", } def friendly_name(model_id: str) -> str: """Turn 'cc/claude-sonnet-4-5-20250929' into 'Claude Sonnet 4.5'.""" raw = model_id.rsplit("/", 1)[-1] clean = re.sub(r"-\d{8}$", "", raw) if clean in MODEL_NAME_MAP: return MODEL_NAME_MAP[clean] if raw in MODEL_NAME_MAP: return MODEL_NAME_MAP[raw] name = clean.replace("-", " ").replace("_", " ") return " ".join(w.capitalize() for w in name.split()) def provider_label(model_id: str) -> str: """Get display label for the provider prefix.""" prefix = model_id.split("/")[0] if "/" in model_id else "" return PROVIDER_NAMES.get(prefix, prefix.upper()) def should_skip(model_id: str) -> bool: """Skip non-coding models.""" lower = model_id.lower() return any(p in lower for p in SKIP_PATTERNS) def fetch_models(base_url: str, api_key: str) -> list: """Fetch model list from 9Router.""" url = f"{base_url}/models" req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"}) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read().decode()) return data.get("data", []) except urllib.error.URLError as e: print(f"ERROR: Failed to fetch models: {e}", file=sys.stderr) sys.exit(1) def build_model_entry(model_id: str) -> dict: """Build an opencode model entry.""" label = friendly_name(model_id) provider = provider_label(model_id) return {"name": f"{provider} - {label}"} def discover(base_url: str, api_key: str) -> dict: """Main discovery logic. Returns {model_id: entry} dict.""" raw_models = fetch_models(base_url, api_key) result = {} for m in raw_models: mid = m.get("id", "") if not mid or mid == "Main" or should_skip(mid): continue result[mid] = build_model_entry(mid) return result def load_config(path: str) -> dict: try: with open(path) as f: return json.load(f) except FileNotFoundError: return {"$schema": "https://opencode.ai/config.json", "provider": {}} except json.JSONDecodeError as e: print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr) sys.exit(1) def save_config(path: str, config: dict): os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "w") as f: json.dump(config, f, indent=2) f.write("\n") def push_to_forgejo(token: str, models: dict): """Push discovered models to Forgejo as JSON.""" if not token: print("WARN: No Forgejo token, skipping push", file=sys.stderr) return data = json.dumps(models, indent=2).encode() import base64 content = base64.b64encode(data).decode() url = f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/contents/discovered_models.json" # Check if file exists first req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) sha = None try: with urllib.request.urlopen(req, timeout=10) as resp: existing = json.loads(resp.read().decode()) sha = existing.get("sha") except urllib.error.HTTPError as e: if e.code != 404: print(f"WARN: Forgejo check failed: {e}", file=sys.stderr) return payload = json.dumps({ "message": f"Auto-discover: {len(models)} models", "content": content, **({"sha": sha} if sha else {}), }).encode() req = urllib.request.Request(url, data=payload, headers={ "Authorization": f"token {token}", "Content-Type": "application/json", }) req.get_method = lambda: "PUT" try: with urllib.request.urlopen(req, timeout=15) as resp: print(f"Pushed to Forgejo: {resp.status}", file=sys.stderr) except urllib.error.URLError as e: print(f"WARN: Forgejo push failed: {e}", file=sys.stderr) def main(): parser = argparse.ArgumentParser(description="Auto-discover 9Router models for opencode") parser.add_argument("--config", default=CONFIG_PATH, help="Path to opencode.json") parser.add_argument("--output", help="Output path (default: same as --config)") parser.add_argument("--dry-run", action="store_true", help="Print result, don't write") parser.add_argument("--push", action="store_true", help="Push discovered models to Forgejo") parser.add_argument("--api-key", default=API_KEY, help="9Router API key") parser.add_argument("--base-url", default=BASE_URL, help="9Router base URL") args = parser.parse_args() if not args.api_key: print("ERROR: No API key. Set NINEROUTER_API_KEY or use --api-key", file=sys.stderr) sys.exit(1) discovered = discover(args.base_url, args.api_key) print(f"Discovered {len(discovered)} models", file=sys.stderr) if args.dry_run: for mid, entry in discovered.items(): print(f" {mid} -> {entry['name']}") return output_path = args.output or args.config config = load_config(args.config) if "provider" not in config: config["provider"] = {} if "9router" not in config["provider"]: config["provider"]["9router"] = { "name": "9router", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": args.base_url, "apiKey": args.api_key, }, "models": {}, } config["provider"]["9router"]["models"] = discovered save_config(output_path, config) print(f"Wrote {len(discovered)} models to {output_path}", file=sys.stderr) if args.push: push_to_forgejo(FORGEJO_TOKEN, discovered) if __name__ == "__main__": main()