check-pricing-sync.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. #!/usr/bin/env python3
  2. """Offline verifier: loop-ops pricing must match claude-api-ops's model table.
  3. loop-estimate.py reads assets/model-pricing.json. That table is a *copy* of the
  4. authoritative "Current Models" table in skills/claude-api-ops/SKILL.md — and a
  5. copy drifts silently (the exact §7 failure mode). This asserts every model in
  6. loop-ops' pricing exists in the claude-api-ops table with matching input/output
  7. prices. Both files are in-repo, so this is a pure OFFLINE consistency check and
  8. safe to gate PR CI (no network). Live model-id drift is owned by
  9. claude-api-ops/scripts/check-model-table.py.
  10. Usage: check-pricing-sync.py [--offline] [--pricing FILE] [--table FILE] [--json]
  11. Input: argv flags only (no stdin).
  12. Output: stdout = drift findings (plain rows, or --json envelope). Data only.
  13. Stderr: the verdict panel, notices, errors.
  14. Exit: 0 in sync, 2 usage, 3 a file missing, 4 unparseable, 10 drift found
  15. --offline is the default and only mode (accepted for parity with the other §7
  16. verifiers invoked by tests/check-resources.sh).
  17. Examples:
  18. check-pricing-sync.py --offline
  19. check-pricing-sync.py --json | jq '.data[]'
  20. """
  21. from __future__ import annotations
  22. import argparse
  23. import json
  24. import os
  25. import re
  26. import sys
  27. from pathlib import Path
  28. EX_OK = 0
  29. EX_USAGE = 2
  30. EX_NOTFOUND = 3
  31. EX_UNPARSEABLE = 4
  32. EX_DRIFT = 10
  33. HERE = Path(__file__).resolve().parent
  34. DEFAULT_PRICING = HERE.parent / "assets" / "model-pricing.json"
  35. DEFAULT_TABLE = HERE.parent.parent / "claude-api-ops" / "SKILL.md"
  36. PRICE_RE = re.compile(r"\$?\s*([0-9]+(?:\.[0-9]+)?)")
  37. class Term:
  38. """Minimal ANSI helper (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the
  39. Python port is inline). Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
  40. bound stream's TTY + encoding so piped data stays plain ASCII."""
  41. _C = {"green": "\033[32m", "red": "\033[31m", "cyan": "\033[36m",
  42. "dim": "\033[2m", "off": "\033[0m"}
  43. def __init__(self, stream=sys.stderr):
  44. enc = (getattr(stream, "encoding", "") or "").lower()
  45. self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
  46. if os.environ.get("FORCE_COLOR"):
  47. self.color = True
  48. elif (os.environ.get("NO_COLOR") is not None
  49. or os.environ.get("TERM") == "dumb"
  50. or not getattr(stream, "isatty", lambda: False)()):
  51. self.color = False
  52. else:
  53. self.color = True
  54. def c(self, name, text):
  55. return f"{self._C.get(name,'')}{text}{self._C['off']}" if self.color else text
  56. def mark(self, ok):
  57. g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
  58. return self.c("green" if ok else "red", g)
  59. def parse_price(cell: str) -> float | None:
  60. m = PRICE_RE.search(cell)
  61. return float(m.group(1)) if m else None
  62. def load_pricing(path: Path) -> dict:
  63. """{model_id: (input_per_mtok, output_per_mtok)} from loop-ops' JSON."""
  64. if not path.is_file():
  65. print(f"error: pricing file not found: {path}", file=sys.stderr)
  66. raise SystemExit(EX_NOTFOUND)
  67. try:
  68. data = json.loads(path.read_text(encoding="utf-8"))
  69. out = {}
  70. for mid, pr in data.get("models", {}).items():
  71. out[mid] = (float(pr["input_per_mtok"]), float(pr["output_per_mtok"]))
  72. if not out:
  73. print(f"error: no models in {path}", file=sys.stderr)
  74. raise SystemExit(EX_UNPARSEABLE)
  75. return out
  76. except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
  77. print(f"error: could not parse pricing file: {exc}", file=sys.stderr)
  78. raise SystemExit(EX_UNPARSEABLE)
  79. def load_table(path: Path) -> dict:
  80. """{model_id: (input_price, output_price)} from the claude-api-ops markdown
  81. 'Current Models' table. Columns: Model | ID | Context | Max Output | Input | Output."""
  82. if not path.is_file():
  83. print(f"error: claude-api-ops table not found: {path}", file=sys.stderr)
  84. raise SystemExit(EX_NOTFOUND)
  85. table: dict = {}
  86. in_table = False
  87. for line in path.read_text(encoding="utf-8").splitlines():
  88. s = line.strip()
  89. low = s.lower()
  90. if s.startswith("|") and "id" in low and "context" in low and "output" in low:
  91. in_table = True
  92. continue
  93. if in_table:
  94. if not s.startswith("|"):
  95. if table: # table ended
  96. break
  97. continue
  98. if set(s) <= set("|-: "): # separator row
  99. continue
  100. cells = [c.strip() for c in s.strip("|").split("|")]
  101. if len(cells) < 6:
  102. continue
  103. mid = cells[1].strip("`").strip()
  104. if not mid.startswith("claude-"):
  105. continue
  106. ip, op = parse_price(cells[4]), parse_price(cells[5])
  107. if ip is not None and op is not None:
  108. table[mid] = (ip, op)
  109. if not table:
  110. print(f"error: no model rows parsed from {path}", file=sys.stderr)
  111. raise SystemExit(EX_UNPARSEABLE)
  112. return table
  113. def main(argv: list[str]) -> int:
  114. p = argparse.ArgumentParser(
  115. prog="check-pricing-sync.py",
  116. description="Verify loop-ops pricing matches claude-api-ops's model table (offline).",
  117. )
  118. p.add_argument("--offline", action="store_true", help="offline consistency check (default/only mode)")
  119. p.add_argument("--pricing", default=str(DEFAULT_PRICING), help="loop-ops model-pricing.json")
  120. p.add_argument("--table", default=str(DEFAULT_TABLE), help="claude-api-ops SKILL.md with the model table")
  121. p.add_argument("--json", action="store_true", help="emit a JSON envelope")
  122. try:
  123. args = p.parse_args(argv)
  124. except SystemExit as exc:
  125. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  126. pricing = load_pricing(Path(args.pricing))
  127. table = load_table(Path(args.table))
  128. findings = []
  129. for mid, (ip, op) in sorted(pricing.items()):
  130. if mid not in table:
  131. findings.append({"model": mid, "issue": "absent from claude-api-ops table",
  132. "loop_ops": [ip, op], "authoritative": None})
  133. continue
  134. tip, top = table[mid]
  135. if abs(ip - tip) > 1e-9 or abs(op - top) > 1e-9:
  136. findings.append({"model": mid, "issue": "price mismatch",
  137. "loop_ops": [ip, op], "authoritative": [tip, top]})
  138. if args.json:
  139. print(json.dumps({
  140. "data": findings,
  141. "meta": {"count": len(findings), "models_checked": len(pricing),
  142. "in_sync": not findings, "schema": "claude-mods.loop-ops.pricing-sync/v1"},
  143. }, indent=2))
  144. else:
  145. for f in findings:
  146. auth = f"authoritative {f['authoritative']}" if f["authoritative"] else "not in table"
  147. print(f"DRIFT {f['model']}: {f['issue']} (loop-ops {f['loop_ops']} vs {auth})")
  148. t = Term(sys.stderr)
  149. ok = not findings
  150. print(f"{t.mark(ok)} pricing-sync: {len(pricing)} model(s) checked, "
  151. f"{len(findings)} drift "
  152. f"{t.c('dim', '(authoritative: claude-api-ops/SKILL.md)')}", file=sys.stderr)
  153. return EX_DRIFT if findings else EX_OK
  154. if __name__ == "__main__":
  155. sys.exit(main(sys.argv[1:]))