hyper.py

 1#!/usr/bin/env python3
 2"""Fetch model metadata from Charm Hyper (or Hyper Staging).
 3
 4Hyper's `/v1/models` endpoint returns all required fields in a single call,
 5no authentication required.
 6
 7Usage:
 8    hyper.py [--staging] [--json | --tsv]
 9"""
10
11from __future__ import annotations
12
13import argparse
14import json
15import sys
16import urllib.error
17import urllib.request
18from typing import Any, TypedDict
19
20from models import Model, clean_name, emit_json, emit_tsv
21
22PROD_URL = "https://hyper.charm.land/v1/models"
23STAGING_URL = "https://hyper.charmcli.dev/v1/models"
24
25
26class HyperModel(TypedDict):
27    id: str
28    display_name: str
29    context_window: int
30    max_output_tokens: int
31    supports_reasoning: bool
32    supports_attachments: bool
33
34
35def _http_get_json(url: str) -> dict[str, Any]:
36    """Perform a GET request and return the parsed JSON body."""
37    req = urllib.request.Request(url)
38    try:
39        with urllib.request.urlopen(req, timeout=30) as resp:
40            body = resp.read().decode("utf-8")
41    except urllib.error.HTTPError as e:
42        sys.exit(f"HTTP {e.code} fetching {url}: {e.read().decode('utf-8', 'replace')}")
43    except urllib.error.URLError as e:
44        sys.exit(f"Network error fetching {url}: {e.reason}")
45    return json.loads(body)
46
47
48def _build_model(raw: HyperModel) -> Model:
49    return Model(
50        id=raw["id"],
51        name=clean_name(raw["display_name"]),
52        context_length=raw["context_window"],
53        max_completion_tokens=raw["max_output_tokens"],
54        supports_reasoning=raw["supports_reasoning"],
55        supports_image=raw["supports_attachments"],
56    )
57
58
59def fetch_models(staging: bool = False) -> list[Model]:
60    url = STAGING_URL if staging else PROD_URL
61    payload = _http_get_json(url)
62    data = payload.get("data") or []
63    return [_build_model(d) for d in data]
64
65
66def main() -> None:
67    parser = argparse.ArgumentParser(description=__doc__)
68    parser.add_argument(
69        "--staging",
70        action="store_true",
71        help="Fetch from Hyper Staging (hyper.charmcli.dev) instead of Hyper.",
72    )
73    fmt = parser.add_mutually_exclusive_group()
74    fmt.add_argument("--tsv", action="store_const", dest="format", const="tsv")
75    fmt.add_argument("--json", action="store_const", dest="format", const="json")
76    parser.set_defaults(format="tsv")
77    args = parser.parse_args()
78
79    models = fetch_models(staging=args.staging)
80
81    if args.format == "json":
82        emit_json(models, sys.stdout)
83    else:
84        emit_tsv(models, sys.stdout)
85
86
87if __name__ == "__main__":
88    main()