| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- #!/usr/bin/env python3
- """Staleness verifier for the claude-api-ops model + cache-minimum tables.
- Guards the two fast-moving fact tables in this skill against silent drift:
- - the "Current Models" table in SKILL.md (ids, pricing, context, output)
- - the per-model prompt-cache minimum table in references/caching-and-cost.md
- Two modes (protocol SKILL-RESOURCE-PROTOCOL.md §7):
- --offline (default): parse both tables, assert internal consistency. No network.
- Exit 4 (VALIDATION) on a malformed/contradictory row.
- --live: curl the Anthropic Models API and compare its model-id set
- against the documented ids. Advisory only.
- Live-mode scope limit: the Models API returns model IDs but NOT pricing, context,
- or output limits. --live therefore verifies model-ID existence/coverage ONLY:
- - a documented id absent from the live list -> DRIFT (retired/typo)
- - a live id newer than anything documented -> DRIFT (table lacks a new model)
- Pricing/context/output drift is out of scope for --live (the API can't confirm it);
- --offline guards their well-formedness, and the SKILL.md "Live Documentation" links
- remain the human cross-check for pricing.
- Usage: check-model-table.py [--offline | --live] [--json] [--skill-dir DIR] [-q]
- Input: reads SKILL.md and references/caching-and-cost.md (resolved relative to
- this script, or --skill-dir)
- Output: stdout = data only (JSON envelope under --json, else a plain summary)
- Stderr: headers, progress, notes, errors
- Exit: 0 ok/consistent, 2 usage, 3 not-found, 4 validation (malformed/contradictory),
- 5 missing-dep (curl, --live only), 7 unavailable (no key / API unreachable),
- 10 drift (live id-set disagrees with the table)
- Examples:
- check-model-table.py --offline
- check-model-table.py --offline --json | python -m json.tool
- ANTHROPIC_API_KEY=sk-... check-model-table.py --live
- check-model-table.py --live # exits 7 (advisory) when the key is unset
- """
- from __future__ import annotations
- import argparse
- import json
- import os
- import re
- import subprocess
- import sys
- from pathlib import Path
- from typing import NoReturn
- # Windows consoles default to cp1252; force UTF-8 so em-dashes/§ in notes don't
- # raise UnicodeEncodeError or print mojibake (matches the repo's standard fix).
- for _stream in (sys.stdout, sys.stderr):
- try:
- _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
- except (AttributeError, ValueError):
- pass
- EXIT_OK = 0
- EXIT_USAGE = 2
- EXIT_NOT_FOUND = 3
- EXIT_VALIDATION = 4
- EXIT_MISSING_DEP = 5
- EXIT_UNAVAILABLE = 7
- EXIT_DRIFT = 10
- SCHEMA = "claude-mods.claude-api-ops.model-table/v1"
- MODELS_API = "https://api.anthropic.com/v1/models?limit=1000"
- ANTHROPIC_VERSION = "2023-06-01"
- # A well-formed alias id: claude-<word>-<digit>... and NO date suffix.
- # Accepts claude-opus-4-8, claude-fable-5, claude-sonnet-4-6, claude-haiku-4-5.
- ID_RE = re.compile(r"^claude-[a-z]+-\d+(?:-\d+)?$")
- # A date suffix looks like an 8-digit run (e.g. -20251114).
- DATE_SUFFIX_RE = re.compile(r"-\d{8}$")
- def note(msg: str, quiet: bool) -> None:
- if not quiet:
- print(msg, file=sys.stderr)
- def fail_validation(message: str, details: dict, json_mode: bool) -> NoReturn:
- if json_mode:
- print(json.dumps({"error": {"code": "VALIDATION", "message": message,
- "details": details}}))
- print(f"ERROR: {message}", file=sys.stderr)
- for k, v in details.items():
- print(f" {k}: {v}", file=sys.stderr)
- sys.exit(EXIT_VALIDATION)
- # ---------------------------------------------------------------------------
- # Parsing
- # ---------------------------------------------------------------------------
- def split_row(line: str) -> list[str]:
- """Split a markdown table row into trimmed cells (drops outer pipes)."""
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
- return cells
- def is_separator(cells: list[str]) -> bool:
- return all(re.fullmatch(r":?-{2,}:?", c or "") for c in cells) and bool(cells)
- def parse_model_table(text: str) -> tuple[list[dict], list[str]]:
- """Parse the SKILL.md 'Current Models' table.
- Columns: Model | ID | Context | Max Output | Input $/MTok | Output $/MTok
- Returns one dict per data row.
- """
- lines = text.splitlines()
- # Locate the header row that contains the ID column and a price column.
- start = None
- for i, line in enumerate(lines):
- low = line.lower()
- if line.lstrip().startswith("|") and "id" in low and "context" in low and "output" in low:
- start = i
- break
- if start is None:
- return [], []
- header = split_row(lines[start])
- rows: list[dict] = []
- # Expect a separator row next, then data rows until a non-table line.
- j = start + 1
- if j < len(lines) and is_separator(split_row(lines[j])):
- j += 1
- while j < len(lines):
- line = lines[j]
- if not line.lstrip().startswith("|"):
- break
- cells = split_row(line)
- if is_separator(cells):
- j += 1
- continue
- if len(cells) >= 6:
- rows.append({
- "name": cells[0],
- "id_cell": cells[1],
- "context": cells[2],
- "max_output": cells[3],
- "input_price": cells[4],
- "output_price": cells[5],
- })
- j += 1
- return rows, header
- def parse_cache_min_table(text: str) -> list[dict]:
- """Parse the caching-and-cost.md 'Minimum prefix tokens' table.
- Columns: Model | Minimum prefix tokens. The Model cell holds friendly names
- (possibly several comma-separated), not ids.
- """
- lines = text.splitlines()
- start = None
- for i, line in enumerate(lines):
- low = line.lower()
- if line.lstrip().startswith("|") and "model" in low and "minimum" in low and "prefix" in low:
- start = i
- break
- if start is None:
- return []
- rows: list[dict] = []
- j = start + 1
- if j < len(lines) and is_separator(split_row(lines[j])):
- j += 1
- while j < len(lines):
- line = lines[j]
- if not line.lstrip().startswith("|"):
- break
- cells = split_row(line)
- if is_separator(cells):
- j += 1
- continue
- if len(cells) >= 2:
- rows.append({"names": cells[0], "min_tokens": cells[1]})
- j += 1
- return rows
- # ---------------------------------------------------------------------------
- # Offline validation
- # ---------------------------------------------------------------------------
- PRICE_RE = re.compile(r"^\$\d+(?:\.\d+)?$")
- SIZE_RE = re.compile(r"^\d+(?:\.\d+)?[KM]$")
- def clean_id(id_cell: str) -> str:
- """Strip backtick code fences from an ID cell."""
- return id_cell.strip().strip("`").strip()
- def validate_offline(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
- skill_md = skill_dir / "SKILL.md"
- cache_md = skill_dir / "references" / "caching-and-cost.md"
- for p in (skill_md, cache_md):
- if not p.is_file():
- if json_mode:
- print(json.dumps({"error": {"code": "NOT_FOUND",
- "message": f"missing file: {p}",
- "details": {}}}))
- print(f"ERROR: required file not found: {p}", file=sys.stderr)
- sys.exit(EXIT_NOT_FOUND)
- note("=== offline model-table consistency check ===", quiet)
- model_rows, _ = parse_model_table(skill_md.read_text(encoding="utf-8"))
- if not model_rows:
- fail_validation("could not locate a non-empty Current Models table in SKILL.md",
- {"file": str(skill_md)}, json_mode)
- documented_ids: list[str] = []
- models_out: list[dict] = []
- for row in model_rows:
- mid = clean_id(row["id_cell"])
- problems = []
- if not ID_RE.match(mid):
- problems.append("id does not match claude-[a-z]+-<digits>")
- if DATE_SUFFIX_RE.search(mid):
- problems.append("id carries a date suffix (should be a bare alias)")
- if not PRICE_RE.match(row["input_price"]):
- problems.append(f"input price not numeric: {row['input_price']!r}")
- if not PRICE_RE.match(row["output_price"]):
- problems.append(f"output price not numeric: {row['output_price']!r}")
- if not SIZE_RE.match(row["context"]):
- problems.append(f"context not a size (e.g. 1M/200K): {row['context']!r}")
- if not SIZE_RE.match(row["max_output"]):
- problems.append(f"max output not a size: {row['max_output']!r}")
- if problems:
- fail_validation(f"malformed model row: {row['name']!r}",
- {"id": mid, "problems": "; ".join(problems)}, json_mode)
- documented_ids.append(mid)
- models_out.append({
- "name": row["name"], "id": mid, "context": row["context"],
- "max_output": row["max_output"],
- "input_price": row["input_price"], "output_price": row["output_price"],
- })
- # No duplicate ids.
- dupes = {x for x in documented_ids if documented_ids.count(x) > 1}
- if dupes:
- fail_validation("duplicate model ids in the table",
- {"ids": ", ".join(sorted(dupes))}, json_mode)
- # Cache-minimum table.
- cache_rows = parse_cache_min_table(cache_md.read_text(encoding="utf-8"))
- if not cache_rows:
- fail_validation("could not locate the cache-minimum table in caching-and-cost.md",
- {"file": str(cache_md)}, json_mode)
- for crow in cache_rows:
- if not re.fullmatch(r"\d+", crow["min_tokens"]):
- fail_validation("cache-minimum value is not an integer",
- {"row": crow["names"], "value": crow["min_tokens"]},
- json_mode)
- # Cross-file consistency: every model NAME (e.g. "Opus 4.8", "Fable 5",
- # "Sonnet 4.6", "Haiku 4.5") in the model table must appear in the cache
- # table's name set, so the two files agree on the model lineup.
- cache_blob = " ".join(c["names"] for c in cache_rows).lower()
- missing_in_cache: list[str] = []
- for m in models_out:
- # Derive the short family+version token, e.g. "Claude Opus 4.8" -> "opus 4.8".
- short = re.sub(r"^claude\s+", "", m["name"], flags=re.I).strip().lower()
- if short not in cache_blob:
- missing_in_cache.append(m["name"])
- if missing_in_cache:
- fail_validation(
- "model(s) in SKILL.md absent from the cache-minimum table — files contradict",
- {"missing": ", ".join(missing_in_cache),
- "hint": "every documented model needs a prompt-cache minimum row"},
- json_mode)
- note(f" {len(models_out)} model rows, all well-formed", quiet)
- note(f" {len(cache_rows)} cache-minimum rows, all integer", quiet)
- note(" cross-file model lineup consistent", quiet)
- note("OK: tables internally consistent.", quiet)
- return {
- "mode": "offline",
- "models": models_out,
- "documented_ids": documented_ids,
- "cache_min_rows": cache_rows,
- "consistent": True,
- }
- # ---------------------------------------------------------------------------
- # Live validation
- # ---------------------------------------------------------------------------
- def fetch_live_ids(quiet: bool) -> list[str] | None:
- """Return the live model-id list, or None if unavailable (advisory)."""
- key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
- if not key:
- note("NOTE: ANTHROPIC_API_KEY is unset - skipping live check (advisory).",
- quiet)
- return None
- cmd = [
- "curl", "-fsS", "--max-time", "20",
- "-H", f"x-api-key: {key}",
- "-H", f"anthropic-version: {ANTHROPIC_VERSION}",
- MODELS_API,
- ]
- try:
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
- except (subprocess.TimeoutExpired, OSError) as exc:
- note(f"NOTE: Models API call failed ({exc}) — advisory, not a failure.",
- quiet)
- return None
- if proc.returncode != 0:
- note(f"NOTE: Models API unreachable (curl exit {proc.returncode}) — advisory.",
- quiet)
- if proc.stderr.strip():
- note(f" {proc.stderr.strip().splitlines()[-1]}", quiet)
- return None
- try:
- payload = json.loads(proc.stdout)
- except json.JSONDecodeError:
- note("NOTE: Models API returned non-JSON — advisory, not a failure.", quiet)
- return None
- data = payload.get("data")
- if not isinstance(data, list):
- note("NOTE: Models API JSON missing 'data' list — advisory.", quiet)
- return None
- return [m.get("id", "") for m in data if isinstance(m, dict) and m.get("id")]
- def validate_live(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
- if not _have("curl"):
- if json_mode:
- print(json.dumps({"error": {"code": "PRECONDITION",
- "message": "curl required for --live",
- "details": {}}}))
- print("ERROR: curl is required for --live", file=sys.stderr)
- sys.exit(EXIT_MISSING_DEP)
- # Reuse offline parse for the documented id set (also validates well-formedness).
- note("=== live model-id coverage check ===", quiet)
- skill_md = skill_dir / "SKILL.md"
- if not skill_md.is_file():
- print(f"ERROR: required file not found: {skill_md}", file=sys.stderr)
- sys.exit(EXIT_NOT_FOUND)
- parsed = parse_model_table(skill_md.read_text(encoding="utf-8"))
- if not parsed or not parsed[0]:
- fail_validation("could not parse the model table for live comparison",
- {"file": str(skill_md)}, json_mode)
- documented = [clean_id(r["id_cell"]) for r in parsed[0]]
- live = fetch_live_ids(quiet)
- if live is None:
- # Advisory: not a failure. Exit 7.
- if json_mode:
- print(json.dumps({"data": {"mode": "live", "status": "unavailable",
- "documented_ids": documented, "live_ids": None},
- "meta": {"schema": SCHEMA, "status": "unavailable"}}))
- sys.exit(EXIT_UNAVAILABLE)
- live_set = set(live)
- doc_set = set(documented)
- # A documented id absent from the live list = drift (retired/typo).
- missing = sorted(doc_set - live_set)
- # A live id NEWER than anything documented = drift (table lacks a new model).
- # Restrict "newer" to well-formed alias ids so we ignore date-suffixed and
- # snapshot variants the docs intentionally don't list.
- live_alias = {m for m in live_set if ID_RE.match(m) and not DATE_SUFFIX_RE.search(m)}
- new_models = sorted(live_alias - doc_set)
- drift = bool(missing or new_models)
- result = {
- "mode": "live",
- "status": "drift" if drift else "ok",
- "documented_ids": documented,
- "live_ids": sorted(live_set),
- "missing_from_live": missing,
- "new_in_live": new_models,
- }
- if drift:
- if missing:
- note("DRIFT: documented id(s) absent from live Models API:", quiet)
- for m in missing:
- note(f" - {m}", quiet)
- if new_models:
- note("DRIFT: live Models API has alias id(s) the table lacks:", quiet)
- for m in new_models:
- note(f" + {m}", quiet)
- if json_mode:
- print(json.dumps({"data": result, "meta": {"schema": SCHEMA,
- "status": "drift"}}))
- else:
- print("DRIFT: model-id table disagrees with the live Models API "
- f"(missing={missing}, new={new_models})")
- sys.exit(EXIT_DRIFT)
- note("OK: every documented id exists live; no newer alias id missing from the table.",
- quiet)
- return result
- def _have(tool: str) -> bool:
- from shutil import which
- return which(tool) is not None
- # ---------------------------------------------------------------------------
- # Main
- # ---------------------------------------------------------------------------
- def main(argv: list[str]) -> int:
- parser = argparse.ArgumentParser(
- prog="check-model-table.py", add_help=True,
- description="Staleness verifier for the claude-api-ops model + cache tables.",
- epilog=(
- "EXAMPLES:\n"
- " check-model-table.py --offline\n"
- " check-model-table.py --offline --json | python -m json.tool\n"
- " ANTHROPIC_API_KEY=sk-... check-model-table.py --live\n"
- " check-model-table.py --live # exits 7 (advisory) when key unset\n"
- ),
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- mode = parser.add_mutually_exclusive_group()
- mode.add_argument("--offline", action="store_true",
- help="parse + assert internal consistency, no network (default)")
- mode.add_argument("--live", action="store_true",
- help="compare documented ids against the live Models API (advisory)")
- parser.add_argument("--json", action="store_true",
- help="emit the JSON envelope on stdout")
- parser.add_argument("--skill-dir", default=None,
- help="skill root (default: parent of this script's dir)")
- parser.add_argument("-q", "--quiet", action="store_true",
- help="suppress stderr progress/notes")
- args = parser.parse_args(argv)
- if args.skill_dir:
- skill_dir = Path(args.skill_dir).resolve()
- else:
- skill_dir = Path(__file__).resolve().parent.parent
- if not skill_dir.is_dir():
- print(f"ERROR: skill dir not found: {skill_dir}", file=sys.stderr)
- return EXIT_NOT_FOUND
- if args.live:
- result = validate_live(skill_dir, args.json, args.quiet)
- else:
- result = validate_offline(skill_dir, args.json, args.quiet)
- if args.json:
- print(json.dumps({"data": result,
- "meta": {"schema": SCHEMA, "status": "ok"}}))
- return EXIT_OK
- if __name__ == "__main__":
- try:
- sys.exit(main(sys.argv[1:]))
- except KeyboardInterrupt:
- sys.exit(EXIT_USAGE)
|