fix: soft-fail model discovery timeouts with retries
Gatekeep evals / eval-floor (push) Successful in 18s
Gatekeep evals / eval-floor (pull_request) Successful in 4s
gatekeep / security 存在未关闭的安全 / Critical 结论 — 合并阻断
Gatekeep / gatekeep (pull_request) Failing after 11m10s

OpenRouter /models list timed out and aborted the whole review.
Retry discovery, and if it still fails skip name validation so
parallel review can proceed.
This commit is contained in:
layue13 committed 2026-10-27 08:47:12 +00:00
1 parent c6c2d071fb
commit f3b62481b0
1 file changed
+79 -44
+79 -44
View File
@@ -118,16 +118,19 @@ def secret_value(key_name: str) -> str:
return "" 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") 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")) return json.loads(resp.read().decode("utf-8"))
def normalize_protocol(p: str) -> str: def normalize_protocol(p: str) -> str:
p = (p or "").strip().lower() p = (p or "").strip().lower()
if p not in PROTOCOL_WIRE: 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 return p
@@ -135,41 +138,72 @@ def wire_protocol(p: str) -> str:
return PROTOCOL_WIRE[normalize_protocol(p)] 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("/") base = base_url.rstrip("/")
wire = wire_protocol(protocol) wire = wire_protocol(protocol)
ids: set[str] = set() if wire not in ("openai-completions", "openai-responses", "anthropic"):
try: die(f"unhandled protocol wire {wire!r}")
if wire in ("openai-completions", "openai-responses"):
data = http_json( last_err: Exception | None = None
f"{base}/models", attempts = int(os.environ.get("GATEKEEP_DISCOVER_RETRIES", "3"))
{"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, 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 []: time.sleep(sleep_s)
mid = m.get("id")
if mid: print(
ids.add(mid) f"gatekeep: model discovery failed for {base} after {attempts} tries "
elif wire == "anthropic": f"({last_err}); skipping name validation and continuing",
data = http_json( file=sys.stderr,
f"{base}/v1/models", )
{ return None
"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
def enrich_provider(pid: str, pconf: dict) -> dict: 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 "" override_csv = sys.argv[3] if len(sys.argv) > 3 else ""
cfg = load_raw(cfg_path) 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_in = cfg["providers"]
providers: dict[str, dict] = { providers: dict[str, dict] = {
pid: enrich_provider(pid, pconf) for pid, pconf in providers_in.items() 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) available = discover_model_ids(pconf["base_url"], pconf["protocol"], api_key)
if not available: if available is not None and len(available) == 0:
die( die(
f"provider {pid}: model list empty — check base_url/protocol/key", f"provider {pid}: model list empty — check base_url/protocol/key",
msg_zh=f"供应商 {pid} 模型列表为空 — 请检查 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"]), "wire_protocol": wire_protocol(pconf["protocol"]),
"key_name": key_name, "key_name": key_name,
"api_key": api_key, "api_key": api_key,
"available_count": len(available), "available_count": len(available) if available is not None else -1,
"_available": available, "_available": available, # None ⇒ skip validation
} }
def to_plan_entry(r: dict) -> dict: def to_plan_entry(r: dict) -> dict:
pid, model = r["provider"], r["model"] pid, model = r["provider"], r["model"]
avail = provider_meta[pid]["_available"] avail = provider_meta[pid]["_available"]
if model not in avail: if avail is not None and model not in avail:
sample = sorted(avail)[:8] sample = sorted(avail)[:8]
die( die(
f"model {model!r} not available on provider {pid} " f"model {model!r} not available on provider {pid} "
@@ -324,11 +364,6 @@ def main() -> None:
) )
os.chmod(secrets_path, 0o600) 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 = { plan = {
"agreement_min": int(cfg.get("agreement_min", 2)), "agreement_min": int(cfg.get("agreement_min", 2)),
"temperature": cfg.get("temperature", 0), "temperature": cfg.get("temperature", 0),