#!/usr/bin/env python3
# Description: Cross-platform client for OpenCode Zen - lists, selects and chats with free or paid models, routing each one to the transport it actually uses (/responses, /chat/completions, /messages, /models/<id>:generateContent, /systemone). Supports images on vision-capable models, keyless anonymous free access, fixed paid keys, always-random for free, output-only mode, and an automatic fallback to the real `opencode` CLI when the free tier refuses a direct call.
# Notes: per-model endpoint and media metadata are fetched on the fly from the models.dev repo (see fetch_model_meta); the static tables below are only an offline fallback.
"""
OpenCode Zen client - list, select and chat with free models.

Two ways to reach a model:

  1. DIRECT - this script talks to https://opencode.ai/zen/v1 itself. Free models
     need no API key: requests go out as "Authorization: Bearer public", which is
     exactly what the opencode client does when unauthenticated.
  2. OPENCODE CLI - the real client, already on PATH:
     `opencode run -m opencode/<id> -f <file> "your prompt"`.
     `--via opencode` forces this; `--via auto` (the default) tries DIRECT first
     and falls back to the CLI when the free tier refuses the call.

IMPORTANT: the anonymous free tier is NOT a quota - it is a request-shape check,
verified 2026-10-20 by capturing a real opencode request through a local logging
proxy and replaying/bisecting its parts. Simplified shapes are refused with 403
FreeTierError on every free model, but a FAITHFUL client shape passes from an
external client. What the check requires, verified probe by probe:
  * stream: true (stream:false is refused even with the rest of the shape)
  * >= 5 entries in "tools" whose function.name is one of opencode's real tool
    names (4 -> 403, 5 -> 200; duplicates count; the schemas themselves are not
    read: minimal stub schemas and tweaked descriptions pass, invented names
    do not)
  * client-format ids: x-opencode-session must look like opencode's own session
    ids ("ses_" + 12 hex + 14 base62); 23 arbitrary characters are refused.
    Project and trace ids may be freshly generated on every call.
  * the client header set (UA opencode/<ver>/cli, accept, trace headers)
Everything else measured irrelevant: no system prompt needed, user text
arbitrary ("hi" passes), "Bearer public" accepted, accept-encoding unnecessary.
No probe produced any quota/rate error, so the refusal is not a budget, and all
ten served free models answer directly once the shape is faithful (verified
on glm, nemotron-vl and beam-free endpoints). In practice: DIRECT works
keyless for text now; images still route through the CLI, where image delivery
is verified end-to-end.

MEDIA (verified by proxying the provider baseURL and capturing the outgoing
request bodies, so these are what is actually SENT, not what models.dev
advertises):
  * image  - forwarded by the CLI on both /chat/completions (image_url) and
             /responses (input_image). 5 of the 9 free models accept images
             (see VERIFIED_VISION, which is measured, not declared); the sixth
             declaring one, minimax-m3.1-flash-free, has no clean code read
             yet.
  * pdf    - forwarded only on /responses models, as input_file. No free model
             is on /responses any more: every free model uses the
             provider-wide @ai-sdk/openai-compatible default, so a free PDF
             attachment is not delivered at all.
  * video  - NOT forwarded. models.dev lists "video" in [modalities].input for
             glm-5.3-flash-free, mimo-v2.6-flash-free and minimax-m3.1-flash-free,
             but opencode drops the attachment before building the request: the
             captured body contains only the text part. So a video input is
             silently lost - this script warns instead of pretending it worked.

LIMITS OF THE VISION PROBE:
  the probe isolates each trial in its own temp directory, but every model runs
  as this user WITH bash enabled, so isolation is by working directory and not a
  sandbox - a model can still walk up and read the harness (the expected codes
  are kept in the log dir). Trials that read the harness are scored UNSCORABLE
  and never as a pass. The consequence to keep in mind when reading
  VERIFIED_VISION: a capability verdict here is solid (every CONFIRMED row came
  back with no tool marker at all, so it cannot have been computed from the
  file), but the text-only models are measured through a weaker instrument,
  since "no tool marker" and "did not cheat" cannot be told apart there.

Mirrors OpenCodeZen.ps1.
"""
import argparse
import base64
import gzip
import json
import mimetypes
import os
import random
import re
import shlex
import shutil
import string
import subprocess
import sys
import time
import urllib.parse
from urllib.request import Request, urlopen
from urllib.error import HTTPError

DEFAULT_SYSTEM_PROMPT = "You are a helpful coding assistant."

# --- Offline fallback tables -------------------------------------------------
# get_endpoint() prefers the live models.dev metadata and only falls back to
# these when the network is unavailable, so they can go slightly stale without
# breaking routing. Kept explicit because prefix guessing alone gets qwen wrong
# (qwen3.8-max is chat/completions while qwen3.8-flash is /messages).
RESPONSES_MODELS = {
    "muse-spark-1.3-contributor-free","muse-spark-1.3",
    "muse-spark-1.2-contributor-free","muse-spark-1.2",
    "gpt-6-astra","gpt-6-sol","gpt-6-luna",
    "gpt-5.6-luna","gpt-5.6-sol","gpt-5.6-terra","gpt-5.5","gpt-5.5-pro","gpt-5.4","gpt-5.4-pro","gpt-5.4-mini","gpt-5.4-nano",
    "gpt-5.3-codex","gpt-5.3-codex-spark","gpt-5.2","gpt-5.2-codex","gpt-5.1","gpt-5.1-codex","gpt-5.1-codex-max","gpt-5.1-codex-mini","gpt-5","gpt-5-codex","gpt-5-nano",
    "grok-4.7","grok-4.6","grok-4.5","grok-build-0.1"
}
MESSAGES_MODELS = {
    "claude-fable-5","claude-fable-5-1","claude-opus-5-5","claude-opus-5","claude-opus-4-8","claude-opus-4-7","claude-opus-4-6","claude-opus-4-5",
    "claude-sonnet-5-5","claude-sonnet-5","claude-sonnet-4-6","claude-sonnet-4-5","claude-sonnet-4","claude-haiku-4-5",
    # NOTE: qwen3.8-max is chat/completions, NOT messages - see CHAT_MODELS.
    "qwen3.8-flash","qwen3.6-plus","qwen3.5-plus"
}
CHAT_MODELS = {
    "deepseek-v4.1-flash","deepseek-v4.1-flash-free","deepseek-v4-pro","deepseek-v4-flash","deepseek-v4-flash-vision-exp",
    "glm-5.3-flash-free","glm-5.3-flash","glm-5.3","glm-5.2","glm-5.1","glm-5",
    "minimax-m3.1-flash-free","minimax-m3","minimax-m2.7","minimax-m2.5",
    "kimi-k3","kimi-k2.7-code","kimi-k2.6","kimi-k2.5",
    # qwen3.8-max is the one Qwen served over chat/completions (others use /messages).
    "qwen3.8-max",
    "beam-free","big-pickle","longcat-2.5-preview-free","mimo-v2.6-flash-free",
    "nemotron-3-ultra-free","nemotron-3.5-lightning-free","nemotron-3.5-super-vl-free",
    # Retired upstream ids stay out of the fallback; see UNAVAILABLE_MODELS.
}
SYSTEMONE_MODELS = {
    "jev-1.13","jev-1.13-free"
}
GEMINI_MODELS = {
    "gemini-3.8-flash","gemini-3.7-flash","gemini-3.6-flash",
    "gemini-3.5-flash","gemini-3.5-flash-lite","gemini-3.1-pro","gemini-3-flash"
}
# Free-looking ids that always fail - deprecated upstream, dropped from
# /zen/v1/models, or 400 from the service. Kept out of the free picker so free
# mode never offers a model that cannot answer (re-checked 2026-10-20).
UNAVAILABLE_MODELS = {
    "deepseek-v4-flash-free",
    "muse-spark-1.2-contributor-free",
    "nemotron-3-ultra-free",
    "fledge-alpha-free",
    "space-bunny-free",
    "mimo-v2.5-free",
    "ling-3.0-flash-fin-free"
}

# Free models verified reachable through the CLI (re-checked 2026-10-20).
# minimax-m3.1-flash-free qualifies on reachability alone: it answered the
# 2026-10-20 matrix run through the CLI, but its image code trial was
# tool-voided, so its vision is unconfirmed (see the note after
# VERIFIED_VISION_NOTE).
# (jev-1.13-free is free but /systemone is not chat.)
CLI_VERIFIED_MODELS = {
    "beam-free","big-pickle","deepseek-v4.1-flash-free","glm-5.3-flash-free",
    "longcat-2.5-preview-free","minimax-m3.1-flash-free","mimo-v2.6-flash-free",
    "nemotron-3.5-lightning-free","nemotron-3.5-super-vl-free",
}

# Best free model per measured capability - the --best recommendation must be
# a tested fact, not a guess from the id list. Both slots sit on the same
# all-rounder right now (1M context, reasoning, tools), so --best text points
# at the newer alternative instead of a second pick.
BEST_FREE_IMAGE_MODEL = "nemotron-3.5-super-vl-free"
BEST_FREE_TEXT_MODEL = "nemotron-3.5-super-vl-free"

# Free ids this IP cannot reach (403 "not available in your country"). Per-region
# and time-bounded, NOT globally dead: they stay in the picker (hiding them would
# deny users in supported regions) and get a call-time warning instead.
REGION_RESTRICTED_MODELS = {
}

# --- VERIFIED media support (measured, not declared) -------------------------
# models.dev says what a model CLAIMS to accept. The pixel-code trial measures
# what it actually accepts through the real opencode CLI, and the two disagree:
# a random 6-character code exists ONLY as pixels - never as text, metadata or
# filename - inside an isolated temp dir holding one attachment. Any model with
# tools can decode a file itself, so a trial counts only when the code comes
# back correct AND no tool call appears in the transcript. Repeating the probe
# gives reliability; the verdict here is capability.
#
#   confirmed  - read the pixel-only code with no tool use
#   refused    - declined the image outright (declared text-only)
#   not listed - unmeasured
VERIFIED_VISION = {
    # Declared image, and read the code with no tool use: 5 of the 9 free
    # models. minimax-m3.1-flash-free also declares image but has no clean
    # code read yet (see the note after VERIFIED_VISION_NOTE).
    "glm-5.3-flash-free": "confirmed",
    "nemotron-3.5-super-vl-free": "confirmed",
    "deepseek-v4.1-flash-free": "confirmed",
    "longcat-2.5-preview-free": "confirmed",
    "mimo-v2.6-flash-free": "confirmed",
    # Declared text-only on models.dev, and none of them can see an image:
    # nemotron-3.5-lightning-free declines in words; big-pickle and beam-free
    # go after the file with bash/PIL instead, so the tool detector voids
    # their trials.
    "big-pickle": "refused",
    "beam-free": "refused",
    "nemotron-3.5-lightning-free": "refused",
}
# Reliability is a separate axis: the counts in VERIFIED_VISION_NOTE come from
# repeated rounds, and one clean code read does not make a model consistent.
VERIFIED_VISION_NOTE = {
    "nemotron-3.5-super-vl-free": "pixel-only codes with no tool use, plus a clean animal-photo read (2026-10-20)",
    "glm-5.3-flash-free": "pixel-only codes with no tool use",
    "deepseek-v4.1-flash-free": "mostly pixel-only codes with no tool use; one trial went after the file with PIL",
    "longcat-2.5-preview-free": "reads codes with no tool use; one refusal (a CAPTCHA call) and one clean miss on the 2026-10-20 run",
    "mimo-v2.6-flash-free": "reads codes with no tool use; one trial answered with unrelated arithmetic",
}
# minimax-m3.1-flash-free has no row here: its single code trial reached the
# code via PIL crop/zoom (tools=yes -> voided), while two same-day content
# reads (animal photo, EXIF-stripped car photo, neutral filename) came back
# correct with no tool use. A clean code read moves it in.
# PDF and video are deliberately NOT listed: both are machine-decodable, so an
# agent can pdftoppm/ffmpeg the answer back without perceiving anything, and
# every correct answer to date came with tool use. Asking the model cannot
# settle them - only capturing the client's outgoing request body can, which
# is what MEDIA_* records.

# --- Dynamic model metadata (models.dev, fetched on the fly, never cached) ---
# Two views of the SAME catalog, both from the anomalyco/models.dev repo:
#
#   1. MODELS_DEV_API - the published index at https://models.dev/api.json.
#      Generated from the repo, with every provider model MERGED against its lab
#      model, so it carries the full record: name, description, family,
#      release_date, last_updated, knowledge, [limit], [modalities], capabilities
#      and cost. One request (~570 KB gzipped, ~0.4 s) covers all 122 opencode
#      models, which is why it is preferred over per-model lookups.
#
#   2. MODELS_DEV_RAW - one override-only TOML per model:
#      https://raw.githubusercontent.com/anomalyco/models.dev/dev/providers/opencode/models/<id>.toml
#      This is the FALLBACK, because provider TOMLs are override-only (see the
#      repo's AGENTS.md): they inherit [modalities]/[limit]/release_date from
#      models/<lab>/<model>.toml via base_model, so most of them omit those keys
#      entirely. Measured: 40 of 86 served models have no [modalities] in the raw
#      file while the merged index lists them. Four models (jev-1.13,
#      jev-1.13-free, test, claude-opus-5-5-backup) are absent from the index and
#      are served by this path.
#
# /zen/v1/models is the only source of the served-model LIST, but it returns just
# {id, object, created, owned_by} - no endpoint, media or specs.
#
#   [provider] npm  -> transport: @ai-sdk/anthropic -> /messages,
#                             @ai-sdk/openai -> /responses,
#                             @ai-sdk/google -> /models/<id>:generateContent,
#                             @ai-sdk/openai-compatible -> /chat/completions
#   [modalities] input -> lists what the model accepts: "text", "image", "video",
#                             "audio", "pdf". This is CAPABILITY, not delivery -
#                             see the MEDIA note in the module docstring: a
#                             declared "video" is not forwarded by the client.
#   [cost] input       -> 0 means free
#   [limit]            -> context / input / output token ceilings
#   canonical_model_id -> "<lab>/<model>" e.g. "openai/gpt-6-luna", so the LAB a
#                             model comes from. Absent for OpenCode-native models
#                             (big-pickle, cat-jam-free, test,
#                             claude-opus-5-5-backup).
#
# Endpoint/media/limit parsing verified against the repo and the merged index:
#   * 50 of 115 model TOMLs omit [modalities] entirely. Absent means UNKNOWN,
#     not text-only; only an explicit ["text"] is trusted.
#   * jev-* (System One) has no [provider] section at all, so it cannot be
#     derived from npm and keeps the explicit SYSTEMONE_MODELS override.
#   * models.dev agrees with the static tables on every endpoint except the
#     intentional jev-* override, and the dynamic lookup routes everything else,
#     so a new model needs no code change.
# Free-model media (re-checked 2026-10-20 on the served free set): 5 of 9
# declare image - deepseek-v4.1-flash-free, glm-5.3-flash-free,
# longcat-2.5-preview-free, mimo-v2.6-flash-free, nemotron-3.5-super-vl-free;
# minimax-m3.1-flash-free also declares image (no clean read yet, see
# VERIFIED_VISION). The other 3 are explicitly text-only:
#   beam-free, big-pickle, nemotron-3.5-lightning-free
# None of the free models carries a [provider] npm section, so they all inherit
# the provider-wide @ai-sdk/openai-compatible default, i.e. /chat/completions.
# That is why no free model can be sent a PDF any more: input_file is only
# forwarded on /responses. (The probe script in this directory lists the same
# table with its declared-image column.)
MODELS_DEV_API = "https://models.dev/api.json"
MODELS_DEV_RAW = ("https://raw.githubusercontent.com/anomalyco/models.dev/dev/"
                  "providers/opencode/models/{mid}.toml")
# providers/opencode/provider.toml sets this as the provider-wide npm default.
MODELS_DEV_DEFAULT_NPM = "@ai-sdk/openai-compatible"
NPM_ENDPOINT = {
    "@ai-sdk/anthropic": "/messages",
    "@ai-sdk/openai": "/responses",
    "@ai-sdk/google": "/models/<id>:generateContent",
    "@ai-sdk/openai-compatible": "/chat/completions",
}

_META_CACHE = {}   # in-memory only, per process; never written to disk
_INDEX = None      # merged models.dev index, same lifetime
_INDEX_TRIED = False


def fetch_models_dev_index(timeout=20):
    """Return the merged opencode provider record: {model_id: {...}}.

    One request for the whole catalog, cached in memory for the process. Never
    raises and never touches the disk: on any failure it returns {} and every
    caller falls back to the per-model TOML below.
    """
    global _INDEX, _INDEX_TRIED
    if _INDEX_TRIED:
        return _INDEX or {}
    _INDEX_TRIED = True          # set BEFORE the fetch: one attempt per process
    index = {}
    try:
        req = Request(MODELS_DEV_API,
                      headers={"User-Agent": "opencode-zen-client",
                               "Accept-Encoding": "gzip"},
                      method="GET")
        with urlopen(req, timeout=timeout) as r:
            raw = r.read()
            if r.headers.get("Content-Encoding") == "gzip":
                raw = gzip.decompress(raw)
        provider = (json.loads(raw.decode("utf-8", "replace")) or {}).get("opencode") or {}
        models = provider.get("models") or {}
        if isinstance(models, dict):
            index = models
    except Exception:
        index = {}
    _INDEX = index
    return index


def _toml_section(text, name):
    m = re.search(r"^\[" + re.escape(name) + r"\]\s*$(.*?)(?=^\[|\Z)", text, re.M | re.S)
    return m.group(1) if m else ""


def _toml_str(body, key):
    m = re.search(r'^\s*' + re.escape(key) + r'\s*=\s*"([^"]*)"', body, re.M)
    return m.group(1) if m else None


def _toml_num(body, key):
    # TOML allows underscore digit separators: context = 1_048_576. Without this
    # the regex would stop at the underscore and report a context of 1.
    m = re.search(r"^\s*" + re.escape(key) + r"\s*=\s*([0-9][0-9_.]*)", body, re.M)
    try:
        return float(m.group(1).replace("_", "")) if m else None
    except ValueError:
        return None


def _toml_str_list(body, key):
    m = re.search(r"^\s*" + re.escape(key) + r"\s*=\s*\[([^\]]*)\]", body, re.M)
    if not m:
        return None
    return [x.strip().strip('"') for x in m.group(1).split(",") if x.strip()]


def _toml_toplevel(text):
    """The part before the first [section] header, so key lookups cannot match
    a same-named key inside a section (e.g. a [[benchmarks]] name)."""
    m = re.search(r"^\[", text, re.M)
    return text[:m.start()] if m else text


def parse_model_toml(text):
    """Parse one models.dev model TOML into endpoint/media/free/spec metadata."""
    npm = _toml_str(_toml_section(text, "provider"), "npm") or MODELS_DEV_DEFAULT_NPM
    mods = _toml_str_list(_toml_section(text, "modalities"), "input")
    cost_in = _toml_num(_toml_section(text, "cost"), "input")
    limit = _toml_section(text, "limit")
    top = _toml_toplevel(text)
    meta = {
        "npm": npm,
        "endpoint": NPM_ENDPOINT.get(npm, "/chat/completions"),
        # None means unknown (section absent) - do NOT collapse it to False.
        "image": None if mods is None else ("image" in mods),
        # Declared capability. Delivery is a separate question: the opencode
        # client forwards image and pdf but drops video even when it is
        # declared here (see the module docstring).
        "modalities": None if mods is None else [m.lower() for m in mods],
        "free": None if cost_in is None else (cost_in == 0),
        # Spec fields. Mostly None on override-only provider TOMLs - the merged
        # index (fetch_models_dev_index) is what actually fills them in.
        "limit": {
            "context": _toml_num(limit, "context"),
            "input": _toml_num(limit, "input"),
            "output": _toml_num(limit, "output"),
        },
    }
    for key in ("name", "description", "family", "release_date", "last_updated",
                "knowledge", "status", "canonical_model_id"):
        meta[key] = _toml_str(top, key)
    for key in CAPABILITY_FLAGS:
        m = re.search(r"^\s*" + re.escape(key) + r"\s*=\s*(true|false)", top, re.M)
        meta[key] = (m.group(1) == "true") if m else None
    return meta


def meta_from_index(entry):
    """Normalise one merged models.dev index record to the same shape as the
    TOML parser, so callers never care which source answered."""
    mods = (entry.get("modalities") or {}).get("input")
    npm = (entry.get("provider") or {}).get("npm") or MODELS_DEV_DEFAULT_NPM
    cost_in = (entry.get("cost") or {}).get("input")
    meta = {
        "npm": npm,
        "endpoint": NPM_ENDPOINT.get(npm, "/chat/completions"),
        "image": None if mods is None else ("image" in mods),
        "modalities": None if mods is None else [m.lower() for m in mods],
        "free": None if cost_in is None else (cost_in == 0),
        "limit": entry.get("limit") or {},
    }
    for key in ("name", "description", "family", "release_date", "last_updated",
                "knowledge", "status", "canonical_model_id"):
        meta[key] = entry.get(key)
    for key in CAPABILITY_FLAGS:
        meta[key] = entry.get(key)
    return meta


def fetch_model_meta(mid, timeout=6):
    """Fetch and parse one model's metadata; None if unavailable.

    Prefers the merged models.dev index (one request, full record). Falls back
    to the per-model override TOML for the handful of models the index does not
    carry (jev-*, test, claude-opus-5-5-backup). Never raises and never touches
    the disk. Callers fall back to the static tables above when this returns
    None (offline, rate-limited, unknown model).
    """
    if mid in _META_CACHE:
        return _META_CACHE[mid]
    meta = None
    entry = fetch_models_dev_index().get(mid)
    if entry:
        meta = meta_from_index(entry)
    else:
        try:
            url = MODELS_DEV_RAW.format(mid=urllib.parse.quote(mid, safe=""))
            req = Request(url, headers={"User-Agent": "opencode-zen-client"}, method="GET")
            with urlopen(req, timeout=timeout) as r:
                body = r.read().decode("utf-8", "replace")
            stripped = body.lstrip()
            # GitHub serves a JSON error payload for unknown paths.
            if stripped.startswith("{") and '"message"' in stripped[:200]:
                body = ""
            if body.strip():
                meta = parse_model_toml(body)
        except Exception:
            meta = None
    _META_CACHE[mid] = meta
    return meta


def vision_capability(mid):
    """True/False when known, None when unknown. None is not False."""
    meta = fetch_model_meta(mid)
    return meta.get("image") if meta else None


def media_capability(mid):
    """Declared input modalities (list of str), or None when unknown.

    This is what models.dev CLAIMS the model accepts, which is not the same as
    what the opencode client will actually forward. For video it is not: the
    attachment is silently dropped. The measured forwarding facts are recorded
    in the MEDIA comment above.
    """
    meta = fetch_model_meta(mid)
    return meta.get("modalities") if meta else None


# What the opencode client actually forwards for a file attachment, verified by
# pointing provider.options.baseURL at a logging proxy and capturing the
# outgoing bodies:
#   image/png,image/jpeg -> image_url (chat/completions) / input_image (responses)
#   application/pdf       -> input_file, and only on /responses models; three
#                            controlled PDF attachments all failed with an
#                            opaque upstream HTTP 500 while a PNG control on
#                            the same model in the same minute read correctly.
#   video/mp4, video/webm -> DROPPED. Not an error, just gone: the captured body
#                            contains only the text part.
# A logged proxy proved the delivery question; the leftover, more specific
# rejection text ("content part type \"file\" is not supported; only text and
# image_url are") did not reproduce and is kept as history only - a fresh 500
# cannot distinguish "type unsupported" from "temporarily unavailable".
# Net effect: pdf reaches PAID models only, while video and audio are dropped
# for everyone.
MEDIA_SILENTLY_DROPPED = ("video", "audio")


# --- Specs view (--specs) ----------------------------------------------------
# Boolean capability flags published by models.dev; shown by the --specs detail
# view for a single model.
CAPABILITY_FLAGS = ("reasoning", "tool_call", "temperature", "structured_output",
                    "attachment", "open_weights")
# The lab that builds a model is canonical_model_id's prefix ("openai/gpt-6-luna"
# -> openai). 35 of the 86 served models have none: they are OpenCode-native
# previews with no upstream lab, so they fall back to the id/family prefix map
# below and are marked "opencode" when even that is silent.
LAB_PREFIXES = (
    ("claude-", "anthropic"), ("gpt-", "openai"), ("gemini-", "google"),
    ("qwen", "alibaba"), ("glm-", "zhipuai"), ("kimi-", "moonshotai"),
    ("minimax-", "minimax"), ("muse-spark-", "meta"), ("deepseek-", "deepseek"),
    ("grok-", "xai"), ("step-", "stepfun"), ("ling-", "inclusionai"),
    ("mimo-", "xiaomi"), ("nemotron-", "nvidia"), ("longcat-", "meituan"),
    ("mistral-", "mistralai"), ("jev-", "typesafe"),
)
NATIVE_LAB = "opencode"


def vision_verdict(mid):
    """'confirmed' / 'refused' / None (unmeasured)."""
    return VERIFIED_VISION.get(mid)


def model_lab(mid, meta=None):
    """Which lab builds this model. '' when models.dev does not say."""
    meta = meta if meta is not None else fetch_model_meta(mid)
    canonical = (meta or {}).get("canonical_model_id") or ""
    if "/" in canonical:
        return canonical.split("/", 1)[0]
    family = ((meta or {}).get("family") or "").lower()
    for prefix, lab in LAB_PREFIXES:
        if mid.startswith(prefix) or family.startswith(prefix):
            return lab
    # No upstream lab recorded: these are OpenCode's own preview models.
    return NATIVE_LAB


def _fmt_num(value):
    """1048576 -> 1,048,576 ; None -> '-'."""
    if value is None:
        return "-"
    try:
        return f"{int(value):,}"
    except (TypeError, ValueError):
        return str(value)


def _fmt_tokens(value):
    """Token ceiling with its unit, or a bare '-' when models.dev omits it."""
    return "-" if value is None else f"{_fmt_num(value)} tokens"


# Detail labels share one column, so pad to the longest of them. Hardcoding 12
# left "structured_output" hanging past every other row.
_DETAIL_LABELS = ("description", "lab", "family", "endpoint", "context",
                  "max input", "max output", "released", "updated", "knowledge",
                  "input media", "vision (tested)", "cost", *CAPABILITY_FLAGS,
                  "status", "source")
_DETAIL_PAD = max(len(_x) for _x in _DETAIL_LABELS)


def _spec_row(mid, meta):
    limit = (meta or {}).get("limit") or {}
    return (mid,
            model_lab(mid, meta),
            _fmt_num(limit.get("context")),
            _fmt_num(limit.get("input")),
            _fmt_num(limit.get("output")),
            (meta or {}).get("release_date") or "-",
            (meta or {}).get("last_updated") or "-")


def format_specs(models):
    """Print the specs table: lab, context/input/output, release + update dates."""
    if not models:
        print("No models returned.", file=sys.stderr)
        return
    rows = [_spec_row(m.get("id", ""), fetch_model_meta(m.get("id", "")))
            for m in models]
    headers = ("id", "lab", "context", "max-in", "max-out", "released", "updated")
    widths = [max(len(headers[i]), max(len(r[i]) for r in rows)) for i in range(len(headers))]
    # The listing is the requested output, so it goes to STDOUT - piping or
    # redirecting must not produce an empty result. Diagnostics stay on stderr.
    print()
    # rstrip every line: padding the last column only adds trailing whitespace,
    # which shows up in diffs and in $().Trim() users.
    print(("  " + "  ".join(h.ljust(w) for h, w in zip(headers, widths))).rstrip())
    print(("  " + "  ".join("-" * w for w in widths)).rstrip())
    for r in rows:
        print(("  " + "  ".join(c.ljust(w) for c, w in zip(r, widths))).rstrip())
    unknown = sum(1 for r in rows if r[2] == "-")
    print(f"\n{len(rows)} models. Token limits from models.dev [limit]; '-' = not published.")
    if unknown:
        print(f"{unknown} model(s) have no published limits (models.dev omits them).")
    print("'opencode' in the lab column means an OpenCode-native model with no upstream lab.")


def show_model_specs(mid):
    """Print every known spec field for one model."""
    meta = fetch_model_meta(mid)
    if not meta:
        print(f"No models.dev metadata for '{mid}'.", file=sys.stderr)
        return
    limit = meta.get("limit") or {}
    mods = meta.get("modalities")
    pad = _DETAIL_PAD
    out = []

    def row(label, value):
        out.append(f"  {label:<{pad}} {value}")

    if meta.get("description"):
        row("description", meta["description"])
    row("lab", model_lab(mid, meta))
    if meta.get("family"):
        row("family", meta["family"])
    row("endpoint", get_endpoint(mid))
    row("context", _fmt_tokens(limit.get("context")))
    row("max input", _fmt_tokens(limit.get("input")))
    row("max output", _fmt_tokens(limit.get("output")))
    row("released", meta.get("release_date") or "-")
    row("updated", meta.get("last_updated") or "-")
    if meta.get("knowledge"):
        row("knowledge", f"{meta['knowledge']} (cutoff)")
    if mods is None:
        row("input media", "unknown (models.dev publishes no [modalities])")
    else:
        dropped = [m for m in mods if m in MEDIA_SILENTLY_DROPPED]
        note = f"  (!{','.join(dropped)} dropped by the opencode client)" if dropped else ""
        row("input media", f"{', '.join(mods)}{note}")
    # Measured beats declared: models.dev lists what the model claims to accept.
    measured = vision_verdict(mid)
    if measured == "confirmed":
        rel = VERIFIED_VISION_NOTE.get(mid)
        row("vision (tested)", f"CONFIRMED - reads images" + (f" ({rel})" if rel else ""))
    elif measured == "refused":
        row("vision (tested)", "REFUSED the attachment outright")
    else:
        row("vision (tested)", "not measured")
    if meta.get("free") is True:
        row("cost", "free")
    elif meta.get("free") is False:
        row("cost", "paid (see models.dev for $/Mtok)")
    else:
        row("cost", "unknown")
    for cap in CAPABILITY_FLAGS:
        if isinstance(meta.get(cap), bool):
            row(cap, "yes" if meta[cap] else "no")
    if meta.get("status"):
        row("status", meta["status"])
    row("source", "models.dev index" if mid in fetch_models_dev_index()
        else "models.dev (per-model TOML fallback)")
    # Built as one block so a long description cannot interleave with the rest.
    print(f"\n{mid}  ({meta.get('name') or '-'})\n" + "\n".join(out))


def _is_responses(mid):
    if mid in RESPONSES_MODELS:
        return True
    # prefix fallback so future gpt/grok/muse-spark models route correctly
    return mid.startswith("gpt-") or mid.startswith("grok-") or mid.startswith("muse-spark-")

def _is_messages(mid):
    # An explicit chat/completions entry wins over the "qwen*" prefix fallback,
    # otherwise qwen3.8-max would be misrouted to /messages.
    if mid in CHAT_MODELS:
        return False
    if mid in MESSAGES_MODELS:
        return True
    return mid.startswith("claude-") or mid.startswith("qwen")

def _is_systemone(mid):
    if mid in SYSTEMONE_MODELS:
        return True
    return mid.startswith("jev-")

def _is_gemini(mid):
    if mid in GEMINI_MODELS:
        return True
    return mid.startswith("gemini-")


def get_endpoint(mid):
    # System One is checked first: jev-* carries no [provider] npm in models.dev,
    # so the dynamic lookup below would report /chat/completions for it.
    if _is_systemone(mid):
        return "/systemone"
    # Preferred: derive the transport from the model's [provider] npm.
    meta = fetch_model_meta(mid)
    if meta:
        return meta["endpoint"]
    # Offline / unknown model: fall back to the static tables above.
    if _is_responses(mid):
        return "/responses"
    if _is_messages(mid):
        return "/messages"
    if _is_gemini(mid):
        return "/models/<id>:generateContent"
    return "/chat/completions"


def is_free_model(mid):
    """True/False when models.dev reports a cost, else fall back to the id heuristic.

    The id heuristic is the only option offline: /zen/v1/models returns just
    {id, object, created, owned_by}, with no cost metadata.
    """
    meta = fetch_model_meta(mid)
    if meta and meta.get("free") is not None:
        return meta["free"]
    return "free" in mid or "big-pickle" in mid


def filter_free_models(models):
    return [m for m in models
            if m.get("id", "") not in UNAVAILABLE_MODELS and is_free_model(m.get("id", ""))]


def new_random_zen_key():
    chars = string.ascii_letters + string.digits
    return "zen_" + "".join(random.choice(chars) for _ in range(32))

def _rand_token(n=24):
    return "".join(random.choice(string.hexdigits + "abcdef") for _ in range(n))

def _new_session_id():
    # opencode session ids look like "ses_" + 12 hex + 14 base62. Verified
    # 2026-10-20: the free gate format-checks that suffix - a session id of 23
    # arbitrary characters is refused with 403 FreeTierError even when the rest
    # of the shape is faithful.
    tail = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(14))
    return "ses_" + "".join(random.choice("0123456789abcdef") for _ in range(12)) + tail

def get_emulated_client_headers():
    # The header set the free tier accepts from an external client (verified
    # 2026-10-20 by capturing a real opencode request through a local logging
    # proxy and bisecting replay variants). The x-opencode-* ids are generated
    # here per call; the session id must match opencode's own id format (see
    # _new_session_id). Project ids look like a 40-char path hash but are not
    # validated; trace ids are not tied to any session.
    session = _new_session_id()
    trace_id = "".join(random.choice("0123456789abcdef") for _ in range(32))
    span_id = "".join(random.choice("0123456789abcdef") for _ in range(16))
    return {
        "Authorization": "Bearer public",
        "Content-Type": "application/json",
        "Accept": "*/*",
        "User-Agent": "opencode/latest/2.1.1/cli",
        "b3": f"{trace_id}-{span_id}-1-{span_id}",
        "traceparent": f"00-{trace_id}-{span_id}-01",
        "x-opencode-client": "cli",
        "x-opencode-project": "".join(random.choice("0123456789abcdef") for _ in range(40)),
        "x-opencode-session": session,
        "x-opencode-session-id": session,
        "x-session-affinity": session,
        "x-session-id": session,
    }

def emulated_tools():
    # The gate checks tool NAMES, not schemas: 4 entries with real opencode
    # names are refused, 5 pass, duplicates count, and minimal stub schemas
    # pass (verified 2026-10-20). All 12 real names are sent as a margin above
    # the threshold.
    return [{"type": "function",
             "function": {"name": name, "description": "opencode tool",
                          "parameters": {"type": "object", "properties": {}}}}
            for name in ("edit", "glob", "grep", "question", "read", "shell",
                         "skill", "subagent", "webfetch", "websearch",
                         "write", "execute")]

def get_anonymous_zen_headers():
    # How keyless access actually works (verified in the opencode source,
    # packages/core/src/plugin/provider/opencode.ts):
    #     const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || ...)
    #     if (!hasKey) provider.request.body.apiKey = "public"
    # which provider-options.ts turns into `Authorization: Bearer public`.
    # So "Bearer public" IS the mechanism - not the x-opencode-* headers.
    # Those are only read server-side for metrics and as the sticky-session
    # key (console/.../zen/util/handler.ts); they are kept here because
    # opencode sends them, but they are not what grants anonymous access.
    #
    # Being sent as "Bearer public" is not sufficient on its own: the provider
    # additionally checks that the request has the shape of a real client turn
    # and rejects it with 403 FreeTierError otherwise. That shape CAN be
    # reproduced from an external client (verified 2026-10-20, see
    # get_emulated_client_headers / emulated_tools, which the anonymous chat
    # path uses).
    return {
        "Authorization": "Bearer public",
        "Content-Type": "application/json",
        "User-Agent": "opencode/1.18.32 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
        "x-opencode-client": "cli",
        "x-opencode-project": "global",
        "x-opencode-session": f"ses_{_rand_token()}",
        "x-opencode-request": f"msg_{_rand_token()}",
    }

def _use_anonymous(is_auto_random):
    # An auto-random key is not a real credential - sending it as a Bearer token
    # only ever yields 401 "Invalid API key". So the key is IGNORED for the
    # request and we fall back to anonymous (opencode-compatible) headers.
    # This intentionally does NOT depend on free_only: a `--no-free-only` run
    # with no key would send a random Bearer and fail 401 instead of working
    # anonymously; a paid model requested that way now fails with a clear
    # FreeTierError instead of a misleading 401.
    return is_auto_random

def get_zen_headers(api_key, is_auto_random):
    if _use_anonymous(is_auto_random):
        return get_anonymous_zen_headers()
    return {"Authorization": f"Bearer {api_key}","Content-Type":"application/json","User-Agent":"opencode-py/1.0","X-Title":"opencode-py"}

def get_chat_headers(api_key, is_auto_random, model_id):
    # Anonymous (auto-random key): opencode-compatible header set on any endpoint.
    if _use_anonymous(is_auto_random):
        return get_anonymous_zen_headers()
    # Anthropic-compatible /messages endpoint expects x-api-key, NOT Bearer.
    # (Bearer yields 401 "Missing API key".)
    if _is_messages(model_id):
        return {"x-api-key": api_key, "anthropic-version": "2023-06-01","Content-Type":"application/json","User-Agent":"opencode-py/1.0","X-Title":"opencode-py"}
    # Native Gemini endpoint expects x-goog-api-key.
    if _is_gemini(model_id):
        return {"x-goog-api-key": api_key,"Content-Type":"application/json","User-Agent":"opencode-py/1.0","X-Title":"opencode-py"}
    return get_zen_headers(api_key, is_auto_random)

def http_get(url, headers, timeout=30):
    req = Request(url, headers=headers, method="GET")
    try:
        with urlopen(req, timeout=timeout) as r:
            return json.loads(r.read().decode())
    except HTTPError as e:
        body = e.read().decode()
        msg = f"GET {url} failed {e.code}: {body}"
        if e.code == 401:
            # Mirrors the 401 catch in OpenCodeZen.ps1 Get-ZenModels: the raw
            # body alone does not say where a usable key comes from.
            msg += " (401 Unauthorized - invalid ZEN_KEY. Get one at https://opencode.ai/auth)"
        raise RuntimeError(msg) from e

FREE_TIER_HINT = ("the provider refused this ANONYMOUS request with 403 FreeTierError. "
                  "This is a request-shape check, not a quota problem: a faithful client "
                  "shape (stream:true, >=5 tools named like opencode's tools, opencode-"
                  "format ids, client headers) is accepted from an external client, and "
                  "this script sends that shape by default. Seeing this means the gate "
                  "has changed upstream. Reliable options: the real client on PATH "
                  "(-Via Auto does this: `opencode run -m opencode/<id> \"your prompt\"`), "
                  "or a real ZEN key (--api-key / $OPENCODE_ZEN_API_KEY).")


def explain_api_error(body_text):
    """Append an actionable hint to a raw error body. '' when there is nothing to add.

    403 FreeTierError is reused for unrelated situations:

      "can only be used from within OpenCode" -> request not shaped like a real
                                                 client turn (NOT a quota problem)
      "not available in your country"         -> geo-restricted

    Both matched the old `FreeTierError in body` test, so a geo-blocked model was
    reported as an exhausted quota - telling the user to wait for a UTC reset that
    could never fix it. Triage on the message text, not just the error type.
    """
    low = body_text.lower()
    if "not available in your country" in low or "not available in your region" in low:
        return (" (this model is GEO-RESTRICTED for your IP's country - it is listed "
                "by /models and marked free by models.dev, but the provider refuses it "
                "here. Retrying will not help and neither will a real ZEN key; pick a "
                "different model.)")
    if "FreeTierError" in body_text or "from within OpenCode" in body_text:
        return f" ({FREE_TIER_HINT})"
    return ""


def run_via_opencode(model_id, prompt, image_path=None, timeout=300):
    """Second option: hand the turn to the real `opencode` client.

    This is the only path that works for free models today (see the module
    docstring). Returns the combined stdout text. Raises RuntimeError when the
    binary is missing or exits non-zero.
    """
    exe = shutil.which("opencode")
    if not exe:
        raise RuntimeError(
            "opencode is not on PATH, so the CLI fallback cannot run. Install it "
            "(https://opencode.ai) or supply a real ZEN key with --api-key.")
    cmd = [exe, "run", "--standalone", "-m", f"opencode/{model_id}"]
    if image_path:
        # -f is how the CLI accepts a file attachment; it becomes image_url /
        # input_image in the outgoing body.
        cmd += ["-f", image_path]
    cmd.append(prompt)
    print(f"ViaOpenCode: {shlex.join(cmd)}", file=sys.stderr)
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        raise RuntimeError(f"opencode CLI timed out after {timeout}s")
    out = (proc.stdout or "").strip()
    err = (proc.stderr or "").strip()
    if proc.returncode != 0:
        raise RuntimeError(f"opencode CLI exited {proc.returncode}: {err[-400:] or out[-400:]}")
    # The CLI prints a "> build . <model>" banner before the answer.
    lines = [ln for ln in out.splitlines() if not ln.startswith("> ")]
    return "\n".join(lines).strip() or out


def image_mime(path):
    """Best-effort mime for an attachment, by extension then magic bytes."""
    mime, _ = mimetypes.guess_type(path)
    if mime:
        return mime
    ext = os.path.splitext(path)[1].lower()
    return {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
            ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf",
            ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
            ".txt": "text/plain", ".csv": "text/csv", ".md": "text/markdown",
            ".json": "application/json"}.get(ext, "application/octet-stream")


def warn_undeliverable_media(model_id, path):
    """Warn when the attachment will not survive the trip to the model.

    Silent data loss is worse than an error: video is declared in models.dev for
    some free models but dropped by the client, so the model would answer as if
    no attachment had been made at all.
    """
    mime = image_mime(path)
    if mime.startswith("video/"):
        print(f"Warning: {model_id} declares video support in models.dev, but the opencode "
              f"client DROPS video attachments - the model will not see {os.path.basename(path)}. "
              f"Extract frames to PNG and attach those instead "
              f"(e.g. ffmpeg -i in.mp4 -vf fps=1 frame%03d.png).", file=sys.stderr)
        return "video"
    if mime.startswith("audio/"):
        print(f"Warning: audio attachments are not delivered to {model_id} either.", file=sys.stderr)
        return "audio"
    if mime == "application/pdf" and _is_messages(model_id):
        print(f"Note: {model_id} is served over /messages; PDF is only forwarded on "
              f"/responses models.", file=sys.stderr)
    return None


def _assemble_sse(raw):
    """Join an opencode-style SSE stream back into one assistant text.

    delta.content pieces are concatenated; tool-call deltas are collected so a
    model that answers with a tool call instead of text is reported, not
    swallowed as an empty answer.
    """
    parts = []
    tool_calls = []
    for line in raw.splitlines():
        line = line.strip()
        if not line.startswith("data:"):
            continue
        data = line[5:].strip()
        if data == "[DONE]":
            break
        try:
            chunk = json.loads(data)
        except ValueError:
            continue
        if isinstance(chunk, dict) and chunk.get("error"):
            raise RuntimeError(str(chunk["error"]))
        for choice in (chunk.get("choices") or []):
            delta = choice.get("delta") or {}
            if delta.get("content"):
                parts.append(delta["content"])
            for tc in (delta.get("tool_calls") or []):
                fname = (tc.get("function") or {}).get("name")
                if fname:
                    tool_calls.append(fname)
    if parts:
        return "".join(parts)
    if tool_calls:
        return f"[model answered with tool call(s): {', '.join(sorted(set(tool_calls)))}]"
    return ""


def http_post_stream(url, headers, body, timeout=240):
    """POST and return an assembled answer: SSE joined as text, else JSON dict.

    The free gate demands stream:true, so the anonymous chat path always
    streams; this wraps the SSE back into the same string/dict interface the
    other invoke_zen_chat branches return. The "Upstream request failed:
    Endpoint is unavailable" 4xx from the broker is transient (measured:
    back-to-back emulated calls hit it roughly every other call while the
    same request succeeds seconds later), so it is retried once here.
    """
    data = json.dumps(body).encode()
    last_error = None
    for attempt in (1, 2):
        req = Request(url, data=data, headers=headers, method="POST")
        try:
            with urlopen(req, timeout=timeout) as r:
                ctype = r.headers.get("content-type", "")
                raw = r.read().decode("utf-8", "replace")
            if "text/event-stream" in ctype:
                return _assemble_sse(raw)
            return json.loads(raw)
        except HTTPError as e:
            body_text = e.read().decode()
            last_error = f"POST {url} failed {e.code}: {body_text}"
            msg = last_error + explain_api_error(body_text)
            if attempt == 1 and e.code in (429, 500, 502, 503) \
                    and "Upstream request failed" in body_text:
                time.sleep(10)
                continue
            raise RuntimeError(msg) from e
    raise RuntimeError(last_error)


def http_post(url, headers, body, timeout=120):
    data = json.dumps(body).encode()
    req = Request(url, data=data, headers=headers, method="POST")
    try:
        with urlopen(req, timeout=timeout) as r:
            return json.loads(r.read().decode())
    except HTTPError as e:
        body_text = e.read().decode()
        msg = f"POST {url} failed {e.code}: {body_text}"
        msg += explain_api_error(body_text)
        raise RuntimeError(msg) from e

def get_zen_models(base_url, api_key, is_auto_random, free_only, always_random):
    if always_random and free_only:
        api_key = new_random_zen_key()
        is_auto_random = True
        print(f"AlwaysRandom: using new key {api_key} for list (free anonymous will ignore it)", file=sys.stderr)
    elif always_random and not free_only:
        print(f"AlwaysRandom ignored for paid - using fixed provided key {api_key}", file=sys.stderr)
    headers = get_zen_headers(api_key, is_auto_random)
    res = http_get(f"{base_url}/models", headers, timeout=30)
    if isinstance(res, dict) and "data" in res:
        return res["data"], api_key, is_auto_random
    if isinstance(res, dict) and "models" in res:
        return res["models"], api_key, is_auto_random
    if isinstance(res, list):
        return res, api_key, is_auto_random
    return [res], api_key, is_auto_random

def list_models(models, show_vision=True, quiet=False):
    """Print the model listing, honouring --output-only (answer-only stdout)."""
    if quiet:
        # Touch the metadata anyway so routing/vision stays warm for later use.
        for m in models:
            mid = m.get("id", "")
            get_endpoint(mid)
            if show_vision:
                vision_capability(mid)
        return
    format_models(models, show_vision)


def format_models(models, show_vision=True):
    # The listing is the requested output, so it goes to STDOUT - piping or
    # redirecting must not produce an empty result. Diagnostics stay on stderr.
    if not models:
        print("No models returned.", file=sys.stderr)
        return
    # The free column mirrors Format-ModelsTable in OpenCodeZen.ps1: on the
    # default free-only listing every row is YES, but with --no-free-only it
    # marks a paid model at a glance. is_free_model hits models.dev too
    # (in-memory cached), same as the media column.
    if show_vision:
        print("\n#  id                              endpoint                 free media")
        print("-  --                              --------                 ---- -----")
    else:
        print("\n#  id                              endpoint                 free")
        print("-  --                              --------                 ----")
    for i, m in enumerate(models, 1):
        mid = m.get("id", "")
        endpoint = get_endpoint(mid)
        free = "YES" if is_free_model(mid) else ""
        if not show_vision:
            print(f"{i:2} {mid:32} {endpoint:24} {free}")
            continue
        print(f"{i:2} {mid:32} {endpoint:24} {free:4} {format_media(mid)}")


def format_media(mid):
    """Compact media column: what models.dev declares, minus what is not delivered.

    Video is shown struck through conceptually by the "video!" suffix: it is
    declared but the client drops it, so it must not read as usable.
    """
    mods = media_capability(mid)
    if mods is None:
        return "?"
    extras = [m for m in mods if m != "text"]
    if not extras:
        return "text-only"
    out = "+".join(extras)
    dropped = [m for m in extras if m in MEDIA_SILENTLY_DROPPED]
    if dropped:
        # Say it inline: "image+video!" means video is declared but NOT delivered.
        return out + "!" if len(dropped) == len(extras) else f"{out} (!{'+'.join(dropped)})"
    return out

def probe_reachability(base_url, api_key, is_auto_random, free_only, always_random, models):
    """Try one tiny call per model and report which ones actually answer.

    /zen/v1/models lists models that may still be refused at call time - the
    anonymous free tier is gated on request shape (see the module docstring), so
    "listed" and "usable" are different things. Rather than discover that one
    failed request at a time, probe once and report.
    """
    rows = []
    for m in models:
        mid = m.get("id", "")
        endpoint = get_endpoint(mid)
        if endpoint == "/systemone":
            rows.append((mid, endpoint, "n/a", "not a chat endpoint"))
            continue
        try:
            invoke_zen_chat(base_url, api_key, is_auto_random, free_only, always_random,
                            mid, "reply ok", DEFAULT_SYSTEM_PROMPT)
            rows.append((mid, endpoint, "OK", ""))
        except Exception as e:
            s = str(e)
            if "free tier can only be used" in s or "not a quota problem" in s:
                status, note = "GATED", "anonymous free tier refused this request"
            elif "not available in your country" in s:
                status, note = "GEO", "region-restricted"
            elif "Model is unavailable" in s:
                status, note = "DEAD", "listed but no longer served"
            else:
                status, note = "FAIL", s[:80]
            rows.append((mid, endpoint, status, note))
    return rows


def format_reachability(rows):
    """Print a probe report. The report is the requested output -> stdout."""
    if not rows:
        print("Nothing to probe.", file=sys.stderr)
        return
    w = max(len(r[0]) for r in rows)
    print(f"\n{'model':<{w}}  {'endpoint':<26} {'status':<7} detail")
    print(f"{'-' * w}  {'-' * 26} {'-' * 7} ------")
    for mid, endpoint, status, note in rows:
        print(f"{mid:<{w}}  {endpoint:<26} {status:<7} {note}")
    ok = [r[0] for r in rows if r[2] == "OK"]
    print(f"\n{len(ok)}/{len(rows)} answered.")
    if ok:
        print("Usable now: " + ", ".join(ok))
    if len(ok) < len(rows):
        print("\nEvery model above marked GATED was refused despite the verified client "
              "shape - that means the shape check changed upstream, not that any quota "
              "ran out. Use the CLI path until the shape is re-verified.")
        print("So use the real client, which is already on PATH:\n"
              f"  opencode run --standalone -m opencode/{BEST_FREE_IMAGE_MODEL} -f img.png \"your prompt\"")
        print(f"CLI-verified free models: {', '.join(sorted(CLI_VERIFIED_MODELS))}")
        print("Or set a real ZEN key (https://opencode.ai/auth) to use the direct API path.")


def select_model(models):
    if not models:
        raise SystemExit("No models to select")
    while True:
        print(f"Select model number (1-{len(models)}) or type model id: ", file=sys.stderr, end="")
        try:
            choice = input().strip()
        except EOFError:
            raise SystemExit(1)
        if not choice:
            continue
        if choice.isdigit():
            idx = int(choice)-1
            if 0 <= idx < len(models):
                return models[idx]
        else:
            for m in models:
                if m.get("id") == choice:
                    return m
            for m in models:
                if choice.lower() in m.get("id","").lower():
                    return m
        print("Invalid selection. Try again.", file=sys.stderr)

def encode_image(path):
    """Return (mime, base64) for an image file."""
    mime, _ = mimetypes.guess_type(path)
    if not mime or not mime.startswith("image/"):
        mime = "image/jpeg"
    with open(path, "rb") as f:
        return mime, base64.b64encode(f.read()).decode()

def data_url(mime, b64):
    return f"data:{mime};base64,{b64}"

def warn_if_text_only(model_id, image_path):
    # Only warn when models.dev explicitly says this model is text-only.
    # An absent [modalities] section means UNKNOWN, so stay quiet there.
    if image_path and vision_capability(model_id) is False:
        print(f"Warning: {model_id} is text-only per models.dev - it cannot see the image. "
              f"It refuses the attachment. "
              f"Best free pick: {BEST_FREE_IMAGE_MODEL}.", file=sys.stderr)
    if image_path:
        warn_undeliverable_media(model_id, image_path)


def invoke_zen_chat(base_url, api_key, is_auto_random, free_only, always_random, model_id, prompt,
                    system=DEFAULT_SYSTEM_PROMPT, image_path=None):
    if always_random and free_only:
        api_key = new_random_zen_key()
        is_auto_random = True
        print(f"AlwaysRandom: new key {api_key} for chat (free anonymous will ignore it)", file=sys.stderr)
    elif always_random and not free_only:
        print(f"AlwaysRandom ignored for paid model - using fixed provided key {api_key}", file=sys.stderr)
    headers = get_chat_headers(api_key, is_auto_random, model_id)
    if _is_systemone(model_id):
        raise RuntimeError(f"Model {model_id} uses the /systemone endpoint (typed decision questions, System One / TypeSafe AI), not chat. "
                           f"POST {base_url}/systemone with {{model, state, questions}}.")
    if model_id in REGION_RESTRICTED_MODELS:
        print(f"Warning: {model_id} is region-restricted (403 "
              f"'This model is not available in your country'). Expected to be refused "
              f"from this IP; the API will explain if that changes.", file=sys.stderr)
    warn_if_text_only(model_id, image_path)
    image = encode_image(image_path) if image_path else None   # (mime, b64)

    if _is_gemini(model_id):
        # Native Google Generative Language endpoint:
        # POST {base}/models/{id}:generateContent with x-goog-api-key.
        parts = []
        if prompt:
            parts.append({"text": prompt})
        if image:
            parts.append({"inlineData": {"mimeType": image[0], "data": image[1]}})
        body = {"contents": [{"role": "user", "parts": parts}],
                "systemInstruction": {"parts": [{"text": system}]},
                "generationConfig": {"maxOutputTokens": 4096}}
        res = http_post(f"{base_url}/models/{model_id}:generateContent", headers, body)
        if isinstance(res, dict) and res.get("error"):
            raise RuntimeError(f"API error for {model_id}: {res['error']}")
        texts = []
        for cand in res.get("candidates", []):
            for p in (cand.get("content") or {}).get("parts", []):
                if p.get("text"):
                    texts.append(p["text"])
        if texts:
            return "\n".join(texts)
        return json.dumps(res, indent=2)

    if _is_responses(model_id):
        # OpenAI Responses API. The system prompt belongs in the top-level
        # "instructions" field; "input" carries only the conversation.
        body = {"model": model_id, "instructions": system, "stream": False}
        if image:
            body["input"] = [{"role": "user", "content": [
                {"type": "input_text", "text": prompt},
                {"type": "input_image", "image_url": data_url(*image)}]}]
        else:
            body["input"] = prompt
        res = http_post(f"{base_url}/responses", headers, body)
        if isinstance(res, dict) and res.get("error"):
            raise RuntimeError(f"API error for {model_id}: {res['error']}")
        texts = []
        for item in res.get("output", []):
            for c in (item.get("content") or []):
                if c.get("text"):
                    texts.append(c["text"])
        if texts:
            return "\n".join(texts)
        if res.get("output_text"):
            return res["output_text"]
        return json.dumps(res, indent=2)

    if _is_messages(model_id):
        # Anthropic Messages API. The system prompt is a TOP-LEVEL "system"
        # field, not a message.
        body = {"model": model_id, "max_tokens": 4096, "system": system, "stream": False}
        if image:
            body["messages"] = [{"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image", "source": {"type": "base64", "media_type": image[0], "data": image[1]}}]}]
        else:
            body["messages"] = [{"role": "user", "content": prompt}]
        res = http_post(f"{base_url}/messages", headers, body)
        if isinstance(res, dict) and res.get("error"):
            raise RuntimeError(f"API error for {model_id}: {res['error']}")
        texts = [c["text"] for c in res.get("content", []) if c.get("text")]
        if texts:
            return "\n".join(texts)
        return json.dumps(res, indent=2)

    # OpenAI-compatible chat/completions
    if _use_anonymous(is_auto_random) and not image:
        # Anonymous FREE turn in the verified client shape (re-checked
        # 2026-10-20): stream:true is part of what the gate checks, so the
        # answer is assembled from SSE deltas. The gate does not read the
        # system prompt or the user wording, so none of that is sent. An
        # image_url part inside this shape is NOT verified end-to-end (one
        # probe hit an upstream HTTP 500), so images keep the classic body
        # below, which routes to the CLI fallback in auto mode.
        body = {"model": model_id, "stream": True, "stream_options": {"include_usage": True},
                "max_completion_tokens": 131072, "tools": emulated_tools(),
                "messages": [{"role": "user", "content": prompt}]}
        res = http_post_stream(f"{base_url}/chat/completions",
                               get_emulated_client_headers(), body)
        if isinstance(res, str):
            return res
        if isinstance(res, dict) and res.get("error"):
            raise RuntimeError(f"API error for {model_id}: {res['error']}")
        choices = res.get("choices")
        if choices and choices[0].get("message", {}).get("content"):
            return choices[0]["message"]["content"]
        return json.dumps(res, indent=2)
    if image:
        body = {"model": model_id, "stream": False, "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {"url": data_url(*image)}}]}]}
    else:
        body = {"model": model_id, "stream": False, "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": prompt}]}
    res = http_post(f"{base_url}/chat/completions", headers, body)
    if isinstance(res, dict) and res.get("error"):
        raise RuntimeError(f"API error for {model_id}: {res['error']}")
    choices = res.get("choices")
    if choices and choices[0].get("message", {}).get("content"):
        return choices[0]["message"]["content"]
    return json.dumps(res, indent=2)


def chat(base_url, api_key, is_auto_random, free_only, always_random, model_id, prompt,
         system=DEFAULT_SYSTEM_PROMPT, image_path=None, via="auto"):
    """Route one turn: DIRECT, the opencode CLI, or auto with fallback.

    "auto" is the useful default. Text turns go DIRECT in the verified
    anonymous client shape; the CLI remains the image path and the fallback
    if the gate ever starts refusing the emulated shape again.
    """
    if via == "opencode":
        return run_via_opencode(model_id, prompt, image_path)
    try:
        return invoke_zen_chat(base_url, api_key, is_auto_random, free_only, always_random,
                               model_id, prompt, system, image_path)
    except RuntimeError as e:
        gate = "from within OpenCode" in str(e) or "FreeTierError" in str(e)
        if via != "auto" or not gate:
            raise
        print("Direct call refused by the free tier - falling back to the opencode CLI.",
              file=sys.stderr)
        if model_id not in CLI_VERIFIED_MODELS:
            # Listed but never seen answering - say so rather than pretending.
            print(f"Note: {model_id} is not in the CLI-verified set, "
                  f"so the CLI may report it unavailable. Run --check to re-probe.",
                  file=sys.stderr)
        return run_via_opencode(model_id, prompt, image_path)


def parse_image_turn(text, current_image):
    """Handle a per-turn 'image:<path> [prompt]' prefix.

    Returns (prompt, image_path). Accepts a space after the colon and quoted
    paths containing spaces, e.g.:
        image:/tmp/a.png describe this
        image: /tmp/a.png describe this
        image:"/tmp/my shot.png" what is it
        image:none            -> clear the sticky --image for later turns
    An unparseable or missing path is treated as ordinary text.
    """
    if not text.startswith("image:"):
        return text, current_image
    rest = text[len("image:"):].strip()
    if not rest:
        return text, current_image
    # "image:none" clears the sticky image.
    if rest.split()[0].lower() in ("none", "-"):
        return rest[len(rest.split()[0]):].strip() or "describe this image", None
    try:
        tokens = shlex.split(rest)
    except ValueError:
        tokens = rest.split()
    if not tokens:
        return text, current_image
    path = tokens[0]
    prompt = " ".join(tokens[1:]).strip() or "describe this image"
    if os.path.exists(path):
        return prompt, path
    print(f"Note: image not found: {path}", file=sys.stderr)
    return text, current_image


def find_model(models, wanted):
    """Exact id, then case-insensitive substring. Returns the match or None."""
    for m in models:
        if m.get("id") == wanted:
            return m
    low = wanted.lower()
    for m in models:
        if low in m.get("id", "").lower():
            return m
    return None


def print_best(image_path=""):
    """Print the recommended model + exact command for the given input."""
    kind = "text"
    if image_path:
        mime = image_mime(image_path)
        kind = ("image" if mime.startswith("image/")
                else "pdf" if mime == "application/pdf"
                else "video" if mime.startswith("video/")
                else "audio" if mime.startswith("audio/") else "text")
    print("\nInput type: %s%s" % (kind, f"  ({os.path.basename(image_path)})" if image_path else ""))

    if kind == "video":
        print("\nNo free model can actually see a video, and that is the CLIENT, not the")
        print("  models: opencode drops a video attachment before building the request")
        print("  (verified by capturing the outgoing body - the body contains")
        print("  only the text part). Declared 'video' on free models today:")
        print("    glm-5.3-flash-free, mimo-v2.6-flash-free, minimax-m3.1-flash-free")
        print("\nExtract frames to PNG first:")
        print("  ffmpeg -i input.mp4 -vf fps=1 frame%03d.png")
        print(f"Then attach a frame:\n  opencode run -m opencode/{BEST_FREE_IMAGE_MODEL} -f frame001.png \"what happens here?\"")
        print("\nFor real video understanding use a provider that accepts it directly.")
        return

    if kind == "image":
        print(f"\nBEST free option: {BEST_FREE_IMAGE_MODEL}")
        print(f"  {VERIFIED_VISION_NOTE.get(BEST_FREE_IMAGE_MODEL, 'reads images without tools.')}")
        print("  Attaches as image_url on /chat/completions, which is what every free")
        print("  model uses. Declares text,image - it does not declare video or audio.")
        print("  Runner-up: glm-5.3-flash-free, equally reliable on the code trials.")
        cmd = f'opencode run --standalone -m opencode/{BEST_FREE_IMAGE_MODEL} -f "{image_path}" "your prompt"'
    elif kind == "pdf":
        print("\nNo free model receives a PDF.")
        print("  input_file is only forwarded on /responses transports, and no")
        print("  free model is on /responses any more - they all use")
        print("  /chat/completions, where the client drops the PDF. Controlled")
        print("  PDF attachments failed with an upstream HTTP 500 while a PNG")
        print("  control on the same model in the same minute read correctly -")
        print("  so the attachment type is what fails, not the host.")
        print("\nConvert a page to an image instead:")
        print("  pdftoppm -png -r 150 input.pdf page")
        print(f"Then attach it:\n  opencode run -m opencode/{BEST_FREE_IMAGE_MODEL} -f page-1.png \"summarise this\"")
        print("\nFor real PDF input use a paid /responses model (gpt-6-luna and friends)")
        print("with a ZEN key - https://opencode.ai/auth")
        return
    else:
        print(f"\nBEST free option: {BEST_FREE_TEXT_MODEL} (1M context, reasoning, tools)")
        if BEST_FREE_TEXT_MODEL != BEST_FREE_IMAGE_MODEL:
            print(f"  Runner-up: {BEST_FREE_IMAGE_MODEL} if you may need images later.")
        else:
            # Both --best slots sit on the same measured all-rounder, so point
            # at the newer alternative instead of repeating the pick.
            print("  Newer free option: minimax-m3.1-flash-free (1M context, image+video")
            print("  declared, CLI-reachable on 2026-10-20, but its one code trial was")
            print("  tool-voided - vision unconfirmed, run --check first).")
        cmd = f'opencode run --standalone -m opencode/{BEST_FREE_TEXT_MODEL} "your prompt"'

    print("\nStandalone command:")
    print(f"  {cmd}")
    model = BEST_FREE_IMAGE_MODEL if kind != "text" else BEST_FREE_TEXT_MODEL
    print("\nDirect API (keyless - this script sends the verified client shape):")
    if image_path:
        print(f'  ./OpenCodeZen.py --model {model} --image "{image_path}" --prompt "your prompt"')
    else:
        print(f'  ./OpenCodeZen.py --model {model} --prompt "your prompt"')
    print("  # -> answered directly for text; if the gate ever re-tightens, --via auto falls back to the CLI above.")
    print("\nWith a real ZEN key the direct path works too:")
    print(f'  export OPENCODE_ZEN_API_KEY=zen_...   # https://opencode.ai/auth')


def main():
    p = argparse.ArgumentParser(
        description="OpenCode Zen client - free models by default, no API key needed")
    p.add_argument("--api-key", default=os.getenv("OPENCODE_ZEN_API_KEY") or os.getenv("ZEN_API_KEY") or "",
                   help="Zen API key (default: $OPENCODE_ZEN_API_KEY). Not needed for free models.")
    p.add_argument("--model", default="", help="Model ID")
    p.add_argument("--prompt", default="", help="Single prompt")
    p.add_argument("--list-only", action="store_true", help="Only list models and exit")
    p.add_argument("--no-free-only", dest="free_only", action="store_false", default=True,
                   help="Show paid models too (needs a real API key)")
    p.add_argument("--always-random", action="store_true", help="Rotate a random key for free (ignored for paid)")
    p.add_argument("--output-only", action="store_true",
                   help="Write ONLY the model's answer to stdout (no listing, no prompts, no warnings)")
    p.add_argument("--quiet", action="store_true", help="Alias for --output-only")
    p.add_argument("--endless", action="store_true", help="Never exit the chat loop, ignore exit/quit")
    p.add_argument("--image", default="", help="Path to an image for vision-capable models")
    p.add_argument("--base-url", default="https://opencode.ai/zen/v1", help="Zen base URL")
    p.add_argument("--system-prompt", default=DEFAULT_SYSTEM_PROMPT, help="System prompt")
    p.add_argument("--no-vision-column", action="store_true",
                   help="Skip the per-model vision lookup in listings (fewer network calls)")
    p.add_argument("--check", action="store_true",
                   help="Probe every listed model with one tiny call and report which "
                        "actually answer (listed != usable; the anonymous free tier is "
                        "gated on request shape). Costs one request per model.")
    p.add_argument("--via", choices=("auto", "direct", "opencode"), default="auto",
                   help="auto (default): call the API directly, and if the free tier "
                        "refuses with 403 FreeTierError, retry through the real opencode "
                        "client on PATH. direct: never fall back. opencode: always use the "
                        "CLI, skipping the API entirely.")
    p.add_argument("--best", action="store_true",
                   help="Print the recommended model for the requested input (image/pdf/"
                        "text) with the exact command to run it, then exit. Uses the "
                        "measured tables below, not the id list.")
    p.add_argument("--specs", action="store_true",
                   help="Print specs from models.dev and exit. Lists every model with "
                        "lab, context, max input/output and release/update dates; with "
                        "--model, prints the full record for that one model.")
    args = p.parse_args()

    # --best answers "which model, and how do I run it" without any network call
    # against the provider, so it works even when every free model is refused.
    if args.best:
        print_best(args.image)
        sys.exit(0)

    free_only = args.free_only
    # Same default as the original: with no --model and no --prompt there is
    # nothing to chat about, so print the list and exit. The picker is reached
    # by --prompt without --model; the chat REPL by --model without --prompt.
    list_only = args.list_only or (not args.model and not args.prompt)
    if args.check:
        list_only = True
    # --specs is a read-only catalog view: it prints and exits, like --list-only.
    if args.specs:
        list_only = True
    show_vision = not args.no_vision_column

    output_only = args.output_only or args.quiet
    # Keep a handle on the real stderr: --output-only silences the log stream,
    # but fatal errors must still reach the user.
    _real_stderr = sys.stderr
    if output_only:
        # stderr carries diagnostics; stdout carries the answer alone. The model
        # listing is stdout output too, so it must be suppressed as well -
        # otherwise --output-only would emit a table before the answer.
        sys.stderr = open(os.devnull, "w")

    api_key = args.api_key
    is_auto_random = False
    if not api_key:
        # A random key is only a placeholder: _use_anonymous() ignores it and
        # sends "Bearer public" instead, so free models work without a key.
        api_key = new_random_zen_key()
        is_auto_random = True
        print("No API key provided - free models work anonymously; paid models will not.", file=sys.stderr)
    elif args.always_random and free_only:
        print("AlwaysRandom: replacing the provided key with a random one (free anonymous ignores it)", file=sys.stderr)
        api_key = new_random_zen_key()
        is_auto_random = True
    elif args.always_random and not free_only:
        print(f"AlwaysRandom ignored for paid - using fixed provided key {api_key}", file=sys.stderr)

    # List path: no --model and no --prompt means "just show me the models".
    if list_only and not args.model and not args.prompt:
        models, _, _ = get_zen_models(args.base_url, api_key, is_auto_random, free_only, args.always_random)
        if free_only:
            models = filter_free_models(models)
        if args.check:
            format_reachability(probe_reachability(
                args.base_url, api_key, is_auto_random, free_only, args.always_random, models))
            sys.exit(0)
        if args.specs:
            format_specs(models)
            sys.exit(0)
        list_models(models, show_vision, quiet=output_only)
        print(f"\nTip: --via auto (the default) calls the API and falls back to the real opencode "
              f"client when the free tier refuses - which is every free model. "
              f"Best free image model: {BEST_FREE_IMAGE_MODEL}. "
              f"Use --best to see the exact command for your input, "
              f"--model <id> --prompt 'hi' for one shot, --model <id> to chat, "
              f"or --prompt 'hi' to pick a model from the list. "
              f"A real ZEN key (https://opencode.ai/auth) unlocks the paid catalog and the "
              f"direct API path. Use --check to probe which models actually answer, "
              f"--specs for context/release/lab data.",
              file=sys.stderr)
        sys.exit(0)

    models, api_key, is_auto_random = get_zen_models(args.base_url, api_key, is_auto_random, free_only, args.always_random)
    if free_only:
        models = filter_free_models(models)

    selected = None
    if args.model:
        selected = find_model(models, args.model)
        if not selected and free_only:
            all_models, _, _ = get_zen_models(args.base_url, api_key, is_auto_random, False, args.always_random)
            selected = find_model(all_models, args.model)
            if selected:
                print(f"Note: '{args.model}' is not free - found outside the free filter.", file=sys.stderr)
        if not selected:
            print(f"Model '{args.model}' not found. Use --list-only, or --no-free-only to search paid models.",
                  file=_real_stderr)
            sys.exit(1)
        if args.specs:
            # --specs with --model: the full record for that one model. Print it
            # and exit rather than dropping into the chat REPL. Handled before
            # the "Using model:" line, which is chat-path noise on a read-only
            # view and would land in front of the record the user asked for.
            show_model_specs(selected["id"])
            sys.exit(0)
        print(f"Using model: {selected['id']}", file=sys.stderr)
    else:
        if output_only:
            # --output-only needs a deterministic target; the picker would be
            # invisible (prompts go to stderr) and would hang on a pipe.
            print("--output-only requires --model <id> (the interactive picker is not available in this mode).",
                  file=_real_stderr)
            sys.exit(2)
        if not models:
            print(f"No models returned from {args.base_url}/models", file=sys.stderr)
            sys.exit(1)
        print(f"Available models ({len(models)}):")
        list_models(models, show_vision)
        selected = select_model(models)
        print(f"\nSelected: {selected['id']}", file=sys.stderr)

    model_id = selected["id"]
    if _is_systemone(model_id):
        print(f"Error: {model_id} is a System One model - it answers typed decision questions over "
              f"{args.base_url}/systemone, not chat. Choose a different model.", file=sys.stderr)
        sys.exit(1)

    if args.prompt:
        print(f"\n> {args.prompt}", file=sys.stderr)
        try:
            ans = chat(args.base_url, api_key, is_auto_random, free_only, args.always_random,
                       model_id, args.prompt, args.system_prompt, args.image or None, args.via)
        except Exception as e:
            # Always report failures on the real stderr, even in --output-only
            # mode: a silent non-zero exit is impossible to debug.
            print(f"Error: {e}", file=sys.__stderr__)
            sys.exit(1)
        print(ans)
        if not args.endless:
            sys.exit(0)
        print(f"(endless mode: continuing chat with {model_id}...)", file=sys.stderr)

    print(f"\nEntering chat with {model_id}. Type 'exit' to leave, 'switch' to pick another model, "
          f"'clear' to clear the screen.\n", file=sys.stderr)
    if args.endless:
        print("Endless mode enabled - 'exit'/'quit' ignored, loop forever (Ctrl+C to stop).", file=sys.stderr)
    print("Per-turn images: image:/path/to.png <prompt>  (or image:none to clear)", file=sys.stderr)
    while True:
        try:
            print("You> ", file=sys.stderr, end="", flush=True)
            user_in = input().strip()
        except EOFError:
            if args.endless and sys.stdin.isatty():
                print("(endless: EOF ignored, continuing...)", file=sys.stderr)
                time.sleep(0.5)
                continue
            break
        if not user_in:
            continue
        if not args.endless and user_in in ("exit", "quit", ":q", "/exit"):
            break
        if args.endless and user_in in ("exit", "quit", ":q", "/exit"):
            print(f"(endless: ignoring '{user_in}', continuing...)", file=sys.stderr)
            user_in = f"continue the conversation (user typed {user_in} but endless mode ignores exit)"
        if user_in in ("switch", "/model", "models"):
            list_models(models, show_vision, quiet=output_only)
            selected = select_model(models)
            model_id = selected["id"]
            print(f"Switched to {model_id}", file=sys.stderr)
            continue
        if user_in == "clear":
            print("\033[H\033[J", end="", file=sys.stderr)
            continue
        prompt_text, img = parse_image_turn(user_in, args.image or None)
        try:
            reply = chat(args.base_url, api_key, is_auto_random, free_only, args.always_random,
                               model_id, prompt_text, args.system_prompt, img, args.via)
        except Exception as e:
            # The REAL stderr, not the devnull-swizzled sys.stderr: under
            # --output-only a chat error must still reach the user, mirroring
            # Write-Fatal in OpenCodeZen.ps1.
            print(f"Error: {e}", file=_real_stderr)
            continue
        print(f"{model_id}:", file=sys.stderr)
        print(reply)

if __name__ == "__main__":
    main()
