"""Which OpenRouter :free models still work? (AY Automate, re-runnable)

1. Pulls https://openrouter.ai/api/v1/models and keeps every model whose ID ends in
   ":free" or whose prompt and completion price are both 0.
2. Pulls /api/v1/models/<id>/endpoints for each one: how many providers serve it,
   their status, 1-day uptime, max output tokens and tool support.
3. Checks a watchlist of :free IDs that older guides (and our own directory) still
   recommend, plus their paid sibling IDs, to see which ones are gone.
4. With OPENROUTER_API_KEY set, sends one tiny chat request to every listed :free
   model (3 passes by default) and every watchlist ID ("Reply with the word OK",
   max_tokens 32) and records
   the HTTP status, latency, serving provider and error text. Free models cost $0;
   each call counts against your daily :free request quota (50 or 1,000 per day).

Usage:
  python3 check.py                  # catalog + endpoints only, no key needed
  OPENROUTER_API_KEY=... python3 check.py --probe [--passes 3]
Writes snapshot-<UTC date>.json and prints a Markdown table.
"""
import datetime as dt
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

API = "https://openrouter.ai/api/v1"
UA = {"User-Agent": "ayautomate-free-model-check/1.0", "HTTP-Referer": "https://www.ayautomate.com",
      "X-Title": "AY Automate free model check"}

# :free IDs still recommended by guides (and on /free-models) as of late October 2026.
WATCHLIST = [
    "openai/gpt-oss-120b:free",
    "qwen/qwen3-coder:free",
    "meta-llama/llama-3.3-70b-instruct:free",
    "nousresearch/hermes-3-llama-3.1-405b:free",
    "openai/gpt-oss-20b:free",
    "poolside/laguna-m.1:free",
    "nvidia/nemotron-3-super-120b-a12b:free",
    "qwen/qwen3-next-80b-a3b-instruct:free",
    "deepseek/deepseek-r1:free",
    "deepseek/deepseek-chat-v3-0324:free",
    "mistralai/mistral-7b-instruct:free",
    "google/gemma-3-27b-it:free",
    "meta-llama/llama-4-maverick:free",
    "z-ai/glm-4.5-air:free",
    "moonshotai/kimi-k2:free",
    # other OpenRouter rows in the AY Automate /free-models directory
    "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
    "moonshotai/kimi-k2.6:free",
    "tencent/hy3:free",
    "poolside/laguna-xs.2:free",
    "arcee-ai/trinity-large-thinking:free",
    "nvidia/nemotron-nano-12b-v2-vl:free",
    "nvidia/nemotron-nano-30b-a3b:free",
    "nvidia/nemotron-nano-9b-v2:free",
    "meta-llama/llama-3.2-3b-instruct:free",
    "liquid/lfm-2.5-1.2b-instruct:free",
    "liquid/lfm-2.5-1.2b-thinking:free",
    "openrouter/owl-alpha",
]


def get(url, key=None):
    headers = dict(UA)
    if key:
        headers["Authorization"] = f"Bearer {key}"
    req = urllib.request.Request(url, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return r.status, json.load(r)
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.load(e)
        except Exception:
            return e.code, None


def endpoints(model_id):
    status, body = get(f"{API}/models/{urllib.parse.quote(model_id, safe='/:.')}/endpoints")
    data = (body or {}).get("data") or {}
    eps = data.get("endpoints") or []
    return status, [
        {
            "provider": e.get("provider_name"),
            "status": e.get("status"),
            "uptime_1d": e.get("uptime_last_1d"),
            "context": e.get("context_length"),
            "max_output": e.get("max_completion_tokens"),
            "tools": "tools" in (e.get("supported_parameters") or []),
            "prompt_price": e.get("pricing", {}).get("prompt"),
            "completion_price": e.get("pricing", {}).get("completion"),
        }
        for e in eps
    ]


def probe(model_id, key):
    payload = json.dumps({
        "model": model_id,
        "messages": [{"role": "user", "content": "Reply with the word OK and nothing else."}],
        "max_tokens": 32,
    }).encode()
    headers = dict(UA, **{"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
    req = urllib.request.Request(f"{API}/chat/completions", data=payload, headers=headers)
    t0 = time.time()
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            body = json.load(r)
            code = r.status
    except urllib.error.HTTPError as e:
        code = e.code
        try:
            body = json.load(e)
        except Exception:
            body = None
    except Exception as e:  # timeout, connection reset
        return {"http": None, "ok": False, "error": type(e).__name__, "latency_s": round(time.time() - t0, 2)}
    latency = round(time.time() - t0, 2)
    body = body or {}
    err = body.get("error")
    choice = (body.get("choices") or [{}])[0]
    msg = (choice.get("message") or {})
    text = (msg.get("content") or "").strip()
    if not err and choice.get("error"):
        err = choice["error"]
    return {
        "http": code,
        "ok": code == 200 and not err and (bool(text) or bool(msg.get("reasoning"))),
        "latency_s": latency,
        "provider": body.get("provider"),
        "model": body.get("model"),  # for openrouter/free: the model it routed to
        "answer": text[:60],
        "cost": (body.get("usage") or {}).get("cost"),
        "error": (err or {}).get("message", "")[:200] if isinstance(err, dict) else (str(err)[:200] if err else None),
    }


def main():
    key = os.environ.get("OPENROUTER_API_KEY")
    do_probe = "--probe" in sys.argv and bool(key)
    passes = int(sys.argv[sys.argv.index("--passes") + 1]) if "--passes" in sys.argv else 3
    now = dt.datetime.now(dt.timezone.utc)
    status, catalog = get(f"{API}/models")
    models = catalog["data"]
    paid_ids = {m["id"] for m in models}

    free = []
    for m in models:
        p = m.get("pricing") or {}
        zero = float(p.get("prompt") or 1) == 0 and float(p.get("completion") or 1) == 0
        if not (m["id"].endswith(":free") or zero):
            continue
        st, eps = endpoints(m["id"])
        free.append({
            "id": m["id"],
            "name": m.get("name"),
            "created": dt.datetime.fromtimestamp(m["created"], dt.timezone.utc).date().isoformat(),
            "context": m.get("context_length"),
            "modality": (m.get("architecture") or {}).get("modality"),
            "expiration_date": m.get("expiration_date"),
            "suffix_free": m["id"].endswith(":free"),
            "paid_sibling_listed": m["id"].removesuffix(":free") in paid_ids,
            "endpoints_http": st,
            "endpoints": eps,
        })

    watch = []
    listed = {r["id"] for r in free}
    for wid in WATCHLIST:
        if wid in listed:
            continue
        st, eps = endpoints(wid)
        base = wid.removesuffix(":free")
        bst, beps = endpoints(base)
        paid = sorted(
            (float(e["prompt_price"] or 0) * 1e6, float(e["completion_price"] or 0) * 1e6) for e in beps
        )
        watch.append({
            "id": wid,
            "in_catalog": wid in paid_ids,
            "endpoints_http": st,
            "endpoints": len(eps),
            "paid_id": base,
            "paid_in_catalog": base in paid_ids,
            "paid_endpoints": len(beps),
            "paid_cheapest_per_1m": paid[0] if paid else None,
        })

    if do_probe:
        # Listed models get several passes (a single 429 is often a busy upstream, not a dead model);
        # watchlist IDs get one call, since a retired ID answers 404 every time.
        for n in range(passes):
            for row in free:
                row.setdefault("probes", []).append(dict(probe(row["id"], key), pass_no=n + 1))
                time.sleep(3.5)  # stay under the 20 requests/minute :free limit
        for row in free:
            row["probe"] = next((p for p in row["probes"] if p["ok"]), row["probes"][-1])
            row["ok_passes"] = sum(p["ok"] for p in row["probes"])
        for row in watch:
            row["probe"] = probe(row["id"], key)
            time.sleep(3.5)

    quota = None
    if key:  # /api/v1/key reports this key's :free daily quota (only the counters are kept)
        _, kb = get(f"{API}/key", key)
        quota = ((kb or {}).get("data") or {}).get("free_model_daily_requests")
    snap = {"pulled_at": now.isoformat(timespec="seconds"), "catalog_size": len(models),
            "free_model_daily_requests": quota, "free": free, "watchlist": watch}
    out = f"snapshot-{now.date().isoformat()}.json"
    with open(out, "w") as f:
        json.dump(snap, f, indent=1)

    print(f"Pulled {snap['pulled_at']}: {len(models)} models in catalog, "
          f"{sum(r['suffix_free'] for r in free)} :free IDs, {len(free)} zero-priced IDs in total\n")
    print("| Model ID | Context | Providers | Expires | Probe |")
    print("|---|---|---|---|---|")
    for r in free:
        pr = r.get("probe")
        ptxt = "" if not pr else ("OK %.1fs" % pr["latency_s"] if pr["ok"] else f"{pr['http']} {pr['error'] or ''}"[:60])
        if pr:
            ptxt = f"{r['ok_passes']}/{len(r['probes'])} OK; " + ptxt
        provs = ", ".join(sorted({e["provider"] for e in r["endpoints"]})) or "none"
        print(f"| `{r['id']}` | {r['context']:,} | {provs} | {r['expiration_date'] or ''} | {ptxt} |")
    print("\n| Watchlist ID | Listed | Free endpoints | Paid ID endpoints | Cheapest paid $/1M in/out | Probe |")
    print("|---|---|---|---|---|---|")
    for r in watch:
        pr = r.get("probe")
        ptxt = "" if not pr else ("OK" if pr["ok"] else f"{pr['http']} {pr['error'] or ''}"[:60])
        cp = r["paid_cheapest_per_1m"]
        cps = f"{cp[0]:.4g} / {cp[1]:.4g}" if cp else "n/a"
        print(f"| `{r['id']}` | {'yes' if r['in_catalog'] else 'no'} | {r['endpoints']} | {r['paid_endpoints']} | {cps} | {ptxt} |")
    print(f"\nWrote {out}")


if __name__ == "__main__":
    main()
