diff --git a/discover_models.py b/discover_models.py new file mode 100644 index 0000000..3e51b58 --- /dev/null +++ b/discover_models.py @@ -0,0 +1,321 @@ +#!/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()