@@ -1,62 +1,154 @@
---
name: updating-llm-client-model-lists
-description: Synchronizes model configurations across Zed, Crush, Pi, and ECA from Plexus' /v1/models endpoint. Use when the user asks to update model lists, sync models, refresh available models, or mentions Plexus model availability.
+description: >-
+ Synchronizes model configurations across Zed and Crush from the Plexus proxy
+ and Hyper. Use when the user asks to update model lists, sync models, refresh
+ available models, or mentions Plexus or Hyper model availability.
user-invocable: true
license: LicenseRef-MutuaL-1.2
metadata:
author: Amolith <amolith@secluded.site>
---
-Updates four LLM client configs from the Plexus proxy's available models.
+Updates LLM client configs from two sources:
-## max_completion_tokens
+1. **Plexus** (self-hosted at `harp-willow.snowy-egret.ts.net:4000`) β feeds
+ both Zed (`language_models.openai_compatible.plexus`) and Crush
+ (`providers.plexus.models`).
+2. **Charm Hyper** (`hyper.charm.land`) and **Hyper Staging**
+ (`hyper.charmcli.dev`) β feed Zed only
+ (`language_models.openai_compatible.hyper` and
+ `language_models.openai_compatible.hyper-staging` respectively). Crush manages
+ its own Hyper models; do not touch them.
+
+## Scripts
+
+Two reusable, typed Python scripts live in `scripts/` alongside this skill. They
+fetch and normalise model metadata into a uniform TSV or JSON format. Run them
+with `--json` when piping into a config-apply helper, or `--tsv` (default) for
+review.
+
+| Script | Source | Auth | Notes |
+|--------|--------|------|-------|
+| `scripts/plexus.py` | Plexus management + metadata APIs | 1Password (`op read`) | Unsets `OP_SERVICE_ACCOUNT_TOKEN` internally, retrieves admin key, does two-step alias β metadata lookup. Excludes `nomic-embed-text-v1.5`, `diff-apply`, `fix-json`, and any alias without a `metadata` block. Uses `floor(context_length * 0.2)` for `max_completion_tokens`. |
+| `scripts/hyper.py` | Hyper `/v1/models` | None | Pass `--staging` to target Hyper Staging. Uses `max_output_tokens` verbatim (no 20% rule). |
+
+Both scripts share `scripts/models.py` (the `Model` dataclass, display-name
+cleaner, and TSV/JSON emitters). All scripts are executable and use
+`#!/usr/bin/env python3`.
+
+### Running the scripts
+
+```bash
+SKILL_DIR="$HOME/.agents/skills/updating-llm-client-model-lists/scripts"
+
+# Plexus β TSV for review
+"$SKILL_DIR/plexus.py" --tsv | column -t -s$'\t'
+
+# Plexus β JSON for config apply
+"$SKILL_DIR/plexus.py" --json
+
+# Hyper β TSV
+"$SKILL_DIR/hyper.py" --tsv | column -t -s$'\t'
+
+# Hyper Staging β JSON
+"$SKILL_DIR/hyper.py" --staging --json
+```
+
+The TSV/JSON output is the single source of truth for model IDs, display names,
+context windows, and capability flags. Review the display names in the table,
+and if they need modification, make the modification while editing the configs.
-Always use `floor(context_length * 0.2)` as the max_completion_tokens value for all clients. Do NOT use the `top_provider.max_completion_tokens` field from the API β some models report their entire context window as max completion tokens, which is not a sensible default. 20% of context length is a more practical cap.
+## jq
+
+The `jq` on PATH inside Crush is a shell function, not the real binary. Always
+invoke the real one via `mise exec jq -- jq β¦`.
+
+## JSONC
+
+Zed's `settings.json` is JSONC (allows trailing commas and comments), so stdlib
+`jq` cannot parse it. Use Python with the `json5` module instead:
+
+```python
+import json, json5, pathlib
+src = pathlib.Path("/home/amolith/.config/zed/settings.json")
+data = json5.loads(src.read_text())
+# β¦mutate dataβ¦
+out = json.dumps(data, indent=4, ensure_ascii=False).replace(" ", "\t")
+src.write_text(out)
+```
## Network reachability
-1. Ping `harp-willow.snowy-egret.ts.net` once
+1. Ping `harp-willow.snowy-egret.ts.net` once.
2. If unreachable and Tailscale DNS is disabled, enable it:
```bash
tailscale set --accept-dns=true
```
-3. Test ping again. If still unreachable, STOP and inform the user
+3. Test ping again. If still unreachable, STOP and inform the user.
-Remember to disable after finishing the whole process if it was previously disabled.
+Remember to disable after finishing the whole process if it was previously
+disabled:
```bash
tailscale set --accept-dns=false
```
-## Fetch Plexus models
+## max_completion_tokens
-```bash
-curl -s http://harp-willow.snowy-egret.ts.net:4000/v1/models | \
- jq -r '.data[]
- | select(.id | IN("nomic-embed-text-v1.5", "diff-apply", "fix-json") | not)
- | select(.context_length != null and .context_length > 0)
- | [.id, (.name // .id), (.context_length | tostring),
- ((.context_length * 0.2 | floor | tostring)),
- (if (.supported_parameters | index("reasoning")) then "reasoning" else "" end),
- (if (.architecture.input_modalities | index("image")) then "image" else "" end)]
- | @tsv' | column -t -s $'\t'
-```
+Different sources use different rules:
-This should provide all the info you need in a table. The data in this table is the same as what you'd get directly querying Plexus. The display names are the same. You do not need to query Plexus any other way. Review the display names in the table, and if they need modification, make the modification while editing the configs.
+- **Plexus**: use `floor(context_length * 0.2)`. Do NOT use the
+ `top_provider.max_completion_tokens` field β some models report their entire
+ context window, which is not a sensible default. `scripts/plexus.py` applies
+ this automatically.
+- **Hyper / Hyper Staging**: use the `max_output_tokens` field verbatim.
+ `scripts/hyper.py` uses this directly (no 20% rule).
## Rules
-- Always exclude `nomic-embed-text-v1.5` (embedding model, not supported by any client)
+### Exclusions (Plexus)
+
+- Always exclude `nomic-embed-text-v1.5` (embedding model, not supported by any
+ client)
- Exclude `diff-apply` and `fix-json` from all client model lists
-- Exclude models with no `context_length` field (e.g. voxtral-small, mistral-ocr) unless the user provides values manually
-- Assume every listed model supports tools, regardless of whether Plexus reports `tools` in `supported_parameters`
-- Omit special characters from display names. For example, "MiniMax-M2.7" should become "MiniMax M2.7". "nemotron-3-super" becomes "Nemotron 3 Super".
+- Exclude models with no `context_length` field (e.g. voxtral-small,
+ mistral-ocr) unless the user provides values manually
+- Only aliases with a `metadata` block are fetched; aliases without metadata
+ (like `diff-apply`, `fix-json`, `memri`, `smol`) are implicitly excluded.
+
+### Capabilities
+
+- Assume every Plexus-listed model supports tools, regardless of whether
+ Plexus reports `tools` in `supported_parameters`.
+- For Hyper and Hyper Staging models, every entry supports tools and
+ interleaved reasoning; set `tools: true`, `parallel_tool_calls: true`, and
+ `interleaved_reasoning: true` on every Hyper entry.
+
+### Display names
+
+`scripts/models.py:clean_name()` handles normalisation for both sources:
+- Strip redundant vendor prefixes (`DeepSeek: DeepSeek V4 Pro` β `DeepSeek V4
+ Pro`)
+- Remove parentheses (`(fast)` β `fast`)
+- Replace hyphens with spaces
+- Split brand-version compounds (`Qwen3.6` β `Qwen 3.6`)
+- Normalise capitalisation for known brands and acronyms
+ (DeepSeek, GLM, GPT, MiMo, MiniMax, Qwen, OSS, FP8, INT4, etc.)
+
+When a Plexus alias ID ends in `-h` (indicating a hosted variant), the
+display name from Plexus already reflects the correct form; no special
+suffix handling is needed beyond `clean_name()`.
## Update Zed config
-File: `~/.config/zed/settings.json`
+File: `~/.config/zed/settings.json` (JSONC β edit via Python `json5`, see
+above).
-Update `language_models.openai_compatible.Plexus.available_models[]`. Each entry:
+### Plexus
+
+Update `language_models.openai_compatible.plexus.available_models[]`. Each
+entry:
```json
{
@@ -69,34 +161,86 @@ Update `language_models.openai_compatible.Plexus.available_models[]`. Each entry
"prompt_cache_key": false,
"tools": true,
"parallel_tool_calls": true,
- "images": <true if "image">
+ "images": <supports_image>
}
}
```
-## Update Crush config
+### Hyper
-File: `~/.local/share/chezmoi/dot_config/crush/crush.json`
-
-Update `providers.plexus.models[]`. Each entry:
+Update `language_models.openai_compatible.hyper.available_models[]`. Each
+entry:
```json
{
- "id": "<id>",
- "name": "<name>",
- "context_window": <context_length>,
- "default_max_tokens": <max_completion_tokens>,
- "can_reason": <true if "reasoning">,
- "supports_attachments": <true if "image">
+ "name": "<id>",
+ "display_name": "<name>",
+ "max_tokens": <context_window>,
+ "max_completion_tokens": <max_output_tokens>,
+ "capabilities": {
+ "chat_completions": false,
+ "prompt_cache_key": false,
+ "tools": true,
+ "parallel_tool_calls": true,
+ "interleaved_reasoning": true,
+ "images": <supports_attachments>
+ }
}
```
-After editing: `chezmoi apply ~/.config/crush/crush.json`
-STOP if chezmoi has any output other than success.
+### Hyper Staging
+
+Update `language_models.openai_compatible.hyper-staging.available_models[]`
+with the same schema as Hyper. The `api_url` is
+`https://hyper.charmcli.dev/v1`.
+
+Example apply helper for all three Zed providers:
+
+```python
+import json, json5, pathlib, subprocess
+
+SKILL_DIR = "$HOME/.agents/skills/updating-llm-client-model-lists/scripts"
+src = pathlib.Path.home() / ".config/zed/settings.json"
+data = json5.loads(src.read_text())
+om = data["language_models"]["openai_compatible"]
+
+# Plexus
+plexus = json.loads(subprocess.check_output([SKILL_DIR + "/plexus.py", "--json"]))
+om["plexus"]["available_models"] = [{
+ "name": m["id"], "display_name": m["name"],
+ "max_tokens": m["context_length"],
+ "max_completion_tokens": m["max_completion_tokens"],
+ "capabilities": {"chat_completions": True, "prompt_cache_key": False,
+ "tools": True, "parallel_tool_calls": True, "images": m["supports_image"]},
+} for m in plexus]
+
+# Hyper
+def hyper_entries(staging=False):
+ cmd = [SKILL_DIR + "/hyper.py", "--json"]
+ if staging: cmd.append("--staging")
+ models = json.loads(subprocess.check_output(cmd))
+ return [{
+ "name": m["id"], "display_name": m["name"],
+ "max_tokens": m["context_length"],
+ "max_completion_tokens": m["max_completion_tokens"],
+ "capabilities": {"chat_completions": False, "prompt_cache_key": False,
+ "tools": True, "parallel_tool_calls": True,
+ "interleaved_reasoning": True, "images": m["supports_image"]},
+ } for m in models]
+
+om["hyper"]["available_models"] = hyper_entries()
+om["hyper-staging"] = {
+ "api_url": "https://hyper.charmcli.dev/v1",
+ "available_models": hyper_entries(staging=True),
+}
-## Update Pi config
+out = json.dumps(data, indent=4, ensure_ascii=False).replace(" ", "\t")
+src.write_text(out)
+```
-File: `~/.local/share/chezmoi/dot_config/pi/models.json`
+## Update Crush config
+
+File: `~/.local/share/chezmoi/dot_config/crush/crush.json`
Update `providers.plexus.models[]`. Each entry:
@@ -104,40 +248,29 @@ Update `providers.plexus.models[]`. Each entry:
{
"id": "<id>",
"name": "<name>",
- "reasoning": <true if "reasoning">,
- "input": <["text"] or ["text", "image"]>,
- "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
- "contextWindow": <context_length>,
- "maxTokens": <max_completion_tokens>,
- "compat": {
- "supportsReasoningEffort": <true if "reasoning">,
- "supportsDeveloperRole": false
- }
+ "context_window": <context_length>,
+ "default_max_tokens": <max_completion_tokens>,
+ "can_reason": <supports_reasoning>,
+ "supports_attachments": <supports_image>
}
```
-After editing: `chezmoi apply ~/.config/pi/models.json`
-NEVER use `--force` with chezmoi apply. If it reports the file has changed since last write, STOP and inform the user β they may have local changes not yet added to chezmoi.
-
-## Update ECA config
-
-Reference docs: <https://eca.dev/config/introduction/> and <https://eca.dev/config/models/>
+This file is strict JSON β safe to edit with `mise exec jq` directly:
-File: `~/.config/eca/config.json`
-
-Update `providers.plexus`. ECA custom providers use an OpenAI-compatible provider config with a static model map:
-
-```json
-{
- "api": "openai-chat",
- "url": "http://100.77.116.78:4000/v1",
- "key": "${cmd:fnox get PLEXUS_API_KEY}",
- "reasoningHistory": "all",
- "fetchModels": false,
- "models": {
- "<id>": {}
- }
-}
+```bash
+"$SKILL_DIR/plexus.py" --json > /tmp/plexus_models.json
+mise exec jq -- jq --tab --slurpfile new /tmp/plexus_models.json \
+ '.providers.plexus.models = [$new[0][] | {
+ "id": .id, "name": .name,
+ "context_window": .context_length,
+ "default_max_tokens": .max_completion_tokens,
+ "can_reason": .supports_reasoning,
+ "supports_attachments": .supports_image
+ }]' \
+ ~/.local/share/chezmoi/dot_config/crush/crush.json > /tmp/crush_new.json
+mv /tmp/crush_new.json ~/.local/share/chezmoi/dot_config/crush/crush.json
```
-ECA's custom-provider docs list `api`, `url`, `key`, and `models` as the important fields for OpenAI-compatible providers. Use `api: "openai-chat"` for Plexus, because Plexus exposes `/v1/chat/completions`. Set `fetchModels: false` so ECA uses the Plexus model list from this config rather than attempting dynamic discovery from models.dev.
+After editing, run `chezmoi apply ~/.config/crush/crush.json`. STOP if chezmoi
+has any output other than success. Do not touch `providers.hyper` β Crush
+manages its own Hyper models.
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Fetch model metadata from Charm Hyper (or Hyper Staging).
+
+Hyper's `/v1/models` endpoint returns all required fields in a single call,
+no authentication required.
+
+Usage:
+ hyper.py [--staging] [--json | --tsv]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import urllib.error
+import urllib.request
+from typing import Any, TypedDict
+
+from models import Model, clean_name, emit_json, emit_tsv
+
+PROD_URL = "https://hyper.charm.land/v1/models"
+STAGING_URL = "https://hyper.charmcli.dev/v1/models"
+
+
+class HyperModel(TypedDict):
+ id: str
+ display_name: str
+ context_window: int
+ max_output_tokens: int
+ supports_reasoning: bool
+ supports_attachments: bool
+
+
+def _http_get_json(url: str) -> dict[str, Any]:
+ """Perform a GET request and return the parsed JSON body."""
+ req = urllib.request.Request(url)
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ body = resp.read().decode("utf-8")
+ except urllib.error.HTTPError as e:
+ sys.exit(f"HTTP {e.code} fetching {url}: {e.read().decode('utf-8', 'replace')}")
+ except urllib.error.URLError as e:
+ sys.exit(f"Network error fetching {url}: {e.reason}")
+ return json.loads(body)
+
+
+def _build_model(raw: HyperModel) -> Model:
+ return Model(
+ id=raw["id"],
+ name=clean_name(raw["display_name"]),
+ context_length=raw["context_window"],
+ max_completion_tokens=raw["max_output_tokens"],
+ supports_reasoning=raw["supports_reasoning"],
+ supports_image=raw["supports_attachments"],
+ )
+
+
+def fetch_models(staging: bool = False) -> list[Model]:
+ url = STAGING_URL if staging else PROD_URL
+ payload = _http_get_json(url)
+ data = payload.get("data") or []
+ return [_build_model(d) for d in data]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--staging",
+ action="store_true",
+ help="Fetch from Hyper Staging (hyper.charmcli.dev) instead of Hyper.",
+ )
+ fmt = parser.add_mutually_exclusive_group()
+ fmt.add_argument("--tsv", action="store_const", dest="format", const="tsv")
+ fmt.add_argument("--json", action="store_const", dest="format", const="json")
+ parser.set_defaults(format="tsv")
+ args = parser.parse_args()
+
+ models = fetch_models(staging=args.staging)
+
+ if args.format == "json":
+ emit_json(models, sys.stdout)
+ else:
+ emit_tsv(models, sys.stdout)
+
+
+if __name__ == "__main__":
+ main()
@@ -0,0 +1,133 @@
+"""Shared types and helpers for model-fetching scripts."""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from dataclasses import asdict, dataclass
+from typing import TextIO
+
+
+@dataclass(frozen=True)
+class Model:
+ """Normalized model metadata used to update client configs."""
+
+ id: str
+ name: str
+ context_length: int
+ max_completion_tokens: int
+ supports_reasoning: bool
+ supports_image: bool
+
+
+# Known brand names that need non-trivial capitalisation.
+# Lowercase lookup β correct display form.
+_BRANDS: dict[str, str] = {
+ "deepseek": "DeepSeek",
+ "glm": "GLM",
+ "gpt": "GPT",
+ "gemma": "Gemma",
+ "kimi": "Kimi",
+ "devstral": "Devstral",
+ "llama": "Llama",
+ "mimo": "MiMo",
+ "minimax": "MiniMax",
+ "qwen": "Qwen",
+ "qwen3": "Qwen3",
+ "claude": "Claude",
+ "gemini": "Gemini",
+ "google": "Google",
+ "nvidia": "NVIDIA",
+ "nemotron": "Nemotron",
+}
+
+# Pure-alpha tokens that should be all-caps.
+_ACRONYMS: set[str] = {"oss", "ar"}
+
+# Alpha+digit tokens that should be all-caps.
+_MIXED_ACRONYMS: set[str] = {"fp8", "int4", "nvfp4"}
+
+
+def _split_brand_version(token: str) -> list[str]:
+ """Split a brand-version compound like 'Qwen3.6' into ['Qwen', '3.6'].
+
+ Only splits when the leading letters match a known brand **and** the
+ version contains a decimal point. This avoids splitting 'Qwen3'
+ (no decimal) which should stay together in forms like 'Qwen3 Coder'.
+ """
+ m = re.fullmatch(r"([a-zA-Z]{2,})(\d+\.\d+)", token)
+ if m and m.group(1).lower() in _BRANDS:
+ return [m.group(1), m.group(2)]
+ return [token]
+
+
+def _clean_token(tok: str) -> str:
+ low = tok.lower()
+
+ if low in _BRANDS:
+ return _BRANDS[low]
+ if low in _ACRONYMS:
+ return tok.upper()
+ if low in _MIXED_ACRONYMS:
+ return tok.upper()
+ if tok.isalpha() and tok.isupper() and len(tok) >= 2:
+ return tok
+ if re.fullmatch(r"\d+(?:\.\d+)?", tok):
+ return tok
+
+ # '26b' β '26B', '128e' β '128E'
+ m = re.fullmatch(r"(\d+)([a-zA-Z]+)", tok)
+ if m:
+ return m.group(1) + m.group(2).upper()
+
+ # 'v4' β 'V4', 'k2.5' β 'K2.5', 'a4b' β 'A4B', 'a35b' β 'A35B'
+ m = re.fullmatch(r"([a-zA-Z]+)(\d+(?:\.\d+)?)([a-zA-Z]*)", tok)
+ if m:
+ prefix = m.group(1).capitalize()
+ nums = m.group(2)
+ suffix = m.group(3).upper() if m.group(3) else ""
+ return prefix + nums + suffix
+
+ if tok.isalpha():
+ return tok.capitalize()
+
+ return tok
+
+
+def clean_name(raw: str) -> str:
+ """Normalise a display name from an upstream catalog.
+
+ - Strip redundant vendor prefixes ('DeepSeek: DeepSeek V4 Pro' β 'DeepSeek V4 Pro')
+ - Remove parentheses ('(fast)' β 'fast')
+ - Replace hyphens with spaces
+ - Split brand-version compounds ('Qwen3.6' β 'Qwen 3.6')
+ - Normalise capitalisation for known brands and acronyms
+ """
+ if ":" in raw:
+ raw = raw.split(":")[-1].strip()
+ raw = re.sub(r"\(([^)]+)\)", r"\1", raw)
+ raw = raw.replace("-", " ")
+
+ tokens: list[str] = []
+ for tok in raw.split():
+ tokens.extend(_split_brand_version(tok))
+
+ return " ".join(_clean_token(t) for t in tokens)
+
+
+def emit_tsv(models: list[Model], stream: TextIO) -> None:
+ """Write models as TSV: id, name, ctx, max_tokens, reasoning, image."""
+ for m in models:
+ reasoning = "reasoning" if m.supports_reasoning else ""
+ image = "image" if m.supports_image else ""
+ stream.write(
+ f"{m.id}\t{m.name}\t{m.context_length}\t{m.max_completion_tokens}"
+ f"\t{reasoning}\t{image}\n"
+ )
+
+
+def emit_json(models: list[Model], stream: TextIO) -> None:
+ """Write models as a JSON array (indent=2, trailing newline)."""
+ json.dump([asdict(m) for m in models], stream, indent=2)
+ stream.write("\n")
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""Fetch normalized model metadata from Plexus.
+
+Plexus replaced its single-endpoint `/v1/models` (which used to include full
+metadata) with a flattened alias list. Enriched fields now require a
+two-step lookup:
+
+1. List aliases via the management API (`/v0/management/aliases`) using the
+ admin key. Each alias may carry a `metadata` block pointing at an
+ external catalog entry (source + source_path).
+2. For each alias with metadata, call the public `/v1/metadata/lookup`
+ endpoint to retrieve the normalized record (context_length, capabilities,
+ etc.).
+
+Aliases without a `metadata` block (diff-apply, fix-json, embedding models,
+etc.) are implicitly excluded.
+
+Usage:
+ plexus.py [--json | --tsv]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any, NotRequired, TypedDict
+
+from models import Model, clean_name, emit_json, emit_tsv
+
+PLEXUS_HOST = "harp-willow.snowy-egret.ts.net"
+PLEXUS_PORT = 4000
+BASE_URL = f"http://{PLEXUS_HOST}:{PLEXUS_PORT}"
+
+OP_ITEM = "op://Shared/Plexus/password"
+
+# Models that must never appear in any client config, even if Plexus reports
+# them. This is a safety net β they usually lack metadata and are filtered
+# at the alias step anyway.
+EXCLUDED_IDS: frozenset[str] = frozenset({
+ "nomic-embed-text-v1.5",
+ "diff-apply",
+ "fix-json",
+})
+
+
+class AliasMetadata(TypedDict):
+ source: str
+ source_path: str
+ overrides: NotRequired[dict[str, Any]]
+
+
+class AliasConfig(TypedDict, total=False):
+ target_groups: list[dict[str, Any]]
+ metadata: AliasMetadata | None
+
+
+def _get_admin_token() -> str:
+ """Retrieve the Plexus admin key from 1Password.
+
+ The `op` CLI must run without `OP_SERVICE_ACCOUNT_TOKEN` set, otherwise
+ it tries to use the service account which lacks access to the Shared
+ vault.
+ """
+ env = {k: v for k, v in os.environ.items() if k != "OP_SERVICE_ACCOUNT_TOKEN"}
+ result = subprocess.run(
+ ["op", "read", OP_ITEM],
+ check=True,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ token = result.stdout.strip()
+ if not token:
+ sys.exit("op read returned an empty token")
+ return token
+
+
+def _http_get_json(url: str, headers: dict[str, str] | None = None) -> Any:
+ """Perform a GET request and return the parsed JSON body."""
+ req = urllib.request.Request(url, headers=headers or {})
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ body = resp.read().decode("utf-8")
+ except urllib.error.HTTPError as e:
+ sys.exit(f"HTTP {e.code} fetching {url}: {e.read().decode('utf-8', 'replace')}")
+ except urllib.error.URLError as e:
+ sys.exit(f"Network error fetching {url}: {e.reason}")
+ return json.loads(body)
+
+
+def _list_aliases(token: str) -> dict[str, AliasConfig]:
+ """Fetch the alias map from the management API."""
+ url = f"{BASE_URL}/v0/management/aliases"
+ data = _http_get_json(url, {"x-admin-key": token})
+ if not isinstance(data, dict):
+ sys.exit(f"Unexpected alias response type: {type(data).__name__}")
+ return data
+
+
+def _lookup_metadata(source: str, source_path: str) -> dict[str, Any]:
+ """Fetch normalized metadata for a single alias."""
+ params = urllib.parse.urlencode({"source": source, "source_path": source_path})
+ url = f"{BASE_URL}/v1/metadata/lookup?{params}"
+ payload = _http_get_json(url)
+ return payload.get("data") or {}
+
+
+def _build_model(alias_id: str, meta: dict[str, Any]) -> Model:
+ ctx = meta.get("context_length") or 0
+ supported = meta.get("supported_parameters") or []
+ input_modalities = (meta.get("architecture") or {}).get("input_modalities") or []
+
+ return Model(
+ id=alias_id,
+ name=clean_name(meta.get("name") or alias_id),
+ context_length=ctx,
+ max_completion_tokens=(ctx * 20) // 100,
+ supports_reasoning="reasoning" in supported,
+ supports_image="image" in input_modalities,
+ )
+
+
+def fetch_models() -> list[Model]:
+ token = _get_admin_token()
+ aliases = _list_aliases(token)
+
+ models: list[Model] = []
+ for alias_id, config in sorted(aliases.items()):
+ if alias_id in EXCLUDED_IDS:
+ continue
+ metadata = config.get("metadata")
+ if metadata is None:
+ continue
+ meta = _lookup_metadata(metadata["source"], metadata["source_path"])
+ if not meta.get("context_length"):
+ continue
+ models.append(_build_model(alias_id, meta))
+
+ return models
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ fmt = parser.add_mutually_exclusive_group()
+ fmt.add_argument("--tsv", action="store_const", dest="format", const="tsv")
+ fmt.add_argument("--json", action="store_const", dest="format", const="json")
+ parser.set_defaults(format="tsv")
+ args = parser.parse_args()
+
+ models = fetch_models()
+
+ if args.format == "json":
+ emit_json(models, sys.stdout)
+ else:
+ emit_tsv(models, sys.stdout)
+
+
+if __name__ == "__main__":
+ main()