diff --git a/scripts/resolve-providers.py b/scripts/resolve-providers.py index fdd21fd..5ba6318 100644 --- a/scripts/resolve-providers.py +++ b/scripts/resolve-providers.py @@ -118,16 +118,19 @@ def secret_value(key_name: str) -> str: return "" -def http_json(url: str, headers: dict) -> dict | list: +def http_json(url: str, headers: dict, timeout: float = 25) -> dict | list: req = urllib.request.Request(url, headers=headers, method="GET") - with urllib.request.urlopen(req, timeout=60) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) def normalize_protocol(p: str) -> str: p = (p or "").strip().lower() if p not in PROTOCOL_WIRE: - die(f"unknown protocol {p!r} (want {' | '.join(PROTOCOLS)})") + die( + f"unknown protocol {p!r} (want {' | '.join(PROTOCOLS)})", + msg_zh=f"未知 protocol {p!r}(可选 {' | '.join(PROTOCOLS)})", + ) return p @@ -135,41 +138,72 @@ def wire_protocol(p: str) -> str: return PROTOCOL_WIRE[normalize_protocol(p)] -def discover_model_ids(base_url: str, protocol: str, api_key: str) -> set[str]: +def discover_model_ids(base_url: str, protocol: str, api_key: str) -> set[str] | None: + """Return model id set, or None if discovery failed after retries (skip validation).""" + import time + base = base_url.rstrip("/") wire = wire_protocol(protocol) - ids: set[str] = set() - try: - if wire in ("openai-completions", "openai-responses"): - data = http_json( - f"{base}/models", - {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + if wire not in ("openai-completions", "openai-responses", "anthropic"): + die(f"unhandled protocol wire {wire!r}") + + last_err: Exception | None = None + attempts = int(os.environ.get("GATEKEEP_DISCOVER_RETRIES", "3")) + for attempt in range(max(1, attempts)): + try: + ids: set[str] = set() + if wire in ("openai-completions", "openai-responses"): + data = http_json( + f"{base}/models", + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + for m in data.get("data") or []: + mid = m.get("id") + if mid: + ids.add(mid) + else: + data = http_json( + f"{base}/v1/models", + { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + ) + for m in data.get("data") or []: + mid = m.get("id") + if mid: + ids.add(mid) + return ids + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace")[:400] + last_err = RuntimeError(f"HTTP {e.code} {body}") + # 401/403 are configuration errors — fail closed + if e.code in (401, 403): + die( + f"model discovery failed for {base}: {last_err}", + msg_zh=f"拉取模型列表失败({base}):{last_err} — 请检查 key", + ) + except Exception as e: + last_err = e + if attempt + 1 < attempts: + sleep_s = 2**attempt + print( + f"gatekeep: model discovery retry {attempt + 1}/{attempts} " + f"after {sleep_s}s ({last_err})", + file=sys.stderr, ) - for m in data.get("data") or []: - mid = m.get("id") - if mid: - ids.add(mid) - elif wire == "anthropic": - data = http_json( - f"{base}/v1/models", - { - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - }, - ) - for m in data.get("data") or []: - mid = m.get("id") - if mid: - ids.add(mid) - else: - die(f"unhandled protocol wire {wire!r}") - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace")[:400] - die(f"model discovery failed for {base}: HTTP {e.code} {body}") - except Exception as e: - die(f"model discovery failed for {base}: {e}") - return ids + time.sleep(sleep_s) + + print( + f"gatekeep: model discovery failed for {base} after {attempts} tries " + f"({last_err}); skipping name validation and continuing", + file=sys.stderr, + ) + return None def enrich_provider(pid: str, pconf: dict) -> dict: @@ -198,6 +232,12 @@ def main() -> None: override_csv = sys.argv[3] if len(sys.argv) > 3 else "" cfg = load_raw(cfg_path) + # Resolve language early so die() can speak Chinese when configured + override_lang = (os.environ.get("GATEKEEP_LANGUAGE") or "").strip() + cfg_lang = str(cfg.get("language") or cfg.get("report_language") or "").strip() + language = override_lang or cfg_lang or "en" + os.environ["GATEKEEP_LANGUAGE"] = language + providers_in = cfg["providers"] providers: dict[str, dict] = { pid: enrich_provider(pid, pconf) for pid, pconf in providers_in.items() @@ -278,7 +318,7 @@ def main() -> None: ), ) available = discover_model_ids(pconf["base_url"], pconf["protocol"], api_key) - if not available: + if available is not None and len(available) == 0: die( f"provider {pid}: model list empty — check base_url/protocol/key", msg_zh=f"供应商 {pid} 模型列表为空 — 请检查 base_url / protocol / key", @@ -289,14 +329,14 @@ def main() -> None: "wire_protocol": wire_protocol(pconf["protocol"]), "key_name": key_name, "api_key": api_key, - "available_count": len(available), - "_available": available, + "available_count": len(available) if available is not None else -1, + "_available": available, # None ⇒ skip validation } def to_plan_entry(r: dict) -> dict: pid, model = r["provider"], r["model"] avail = provider_meta[pid]["_available"] - if model not in avail: + if avail is not None and model not in avail: sample = sorted(avail)[:8] die( f"model {model!r} not available on provider {pid} " @@ -324,11 +364,6 @@ def main() -> None: ) os.chmod(secrets_path, 0o600) - # Action input GATEKEEP_LANGUAGE overrides YAML; empty → YAML; missing → en - override_lang = (os.environ.get("GATEKEEP_LANGUAGE") or "").strip() - cfg_lang = str(cfg.get("language") or cfg.get("report_language") or "").strip() - language = override_lang or cfg_lang or "en" - plan = { "agreement_min": int(cfg.get("agreement_min", 2)), "temperature": cfg.get("temperature", 0),