Files
gatekeep/scripts/resolve-providers.py
layue13 f3b62481b0
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
fix: soft-fail model discovery timeouts with retries
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.
2026-10-27 08:47:12 +00:00

395 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Resolve Gatekeep providers from YAML (or legacy JSON).
Each provider entry needs:
key: FREE-FORM secret/env var name (NO required gatekeep_ prefix)
protocol: openai | openai-completions | openai-responses | anthropic
Optional:
base_url, models (defaults applied for known provider ids)
Discovers model lists from the provider API; validates requested names; writes
provider-plan.json for mixed parallel review.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
PROTOCOLS = ("openai", "openai-completions", "openai-responses", "anthropic")
# Wire aliases used by call-model.sh
PROTOCOL_WIRE = {
"openai": "openai-completions",
"openai-completions": "openai-completions",
"openai-compatible": "openai-completions", # legacy
"openai-responses": "openai-responses",
"anthropic": "anthropic",
}
KNOWN_DEFAULTS = {
"openrouter": {
"base_url": "https://openrouter.ai/api/v1",
"protocol": "openai",
},
"anthropic": {
"base_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
"openai": {
"base_url": "https://api.openai.com/v1",
"protocol": "openai",
},
}
def _zh() -> bool:
lang = (os.environ.get("GATEKEEP_LANGUAGE") or "").strip().lower()
return lang.startswith("zh")
def die(msg: str, code: int = 1, msg_zh: str | None = None) -> None:
text = msg_zh if (_zh() and msg_zh) else msg
print(f"gatekeep: {text}", file=sys.stderr)
sys.exit(code)
def load_raw(path: Path) -> dict:
text = path.read_text()
if path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml # type: ignore
except ImportError:
die(
"PyYAML required to load providers.yaml (pip install pyyaml)",
msg_zh="加载 providers.yaml 需要 PyYAML(请 pip install pyyaml 或使用 Action 的 venv 步骤)",
)
raw = yaml.safe_load(text) or {}
else:
raw = json.loads(text)
# Legacy models.json → single openrouter provider
if "providers" not in raw and "models" in raw:
base = raw.get("openrouter_base_url") or KNOWN_DEFAULTS["openrouter"]["base_url"]
raw = {
"agreement_min": raw.get("agreement_min", 2),
"temperature": raw.get("temperature", 0),
"max_tokens": raw.get("max_tokens", 4096),
"providers": {
"openrouter": {
"key": "OPENROUTER_API_KEY",
"protocol": "openai",
"base_url": base,
"models": raw["models"],
}
},
}
# Legacy JSON providers without `key`
providers = raw.get("providers") or {}
for pid, pconf in list(providers.items()):
if not isinstance(pconf, dict):
continue
if "key" not in pconf:
# migrate old shape: invent free-form env name suggestion from provider id
# (not forcing gatekeep_ prefix)
pconf = {**pconf, "key": f"{pid.upper()}_API_KEY"}
if pconf.get("protocol") == "openai-compatible":
pconf["protocol"] = "openai"
providers[pid] = pconf
raw["providers"] = providers
if not providers:
die(f"no providers in {path}")
return raw
def secret_value(key_name: str) -> str:
"""Look up FREE-FORM secret name in the process environment. No prefix rules."""
name = (key_name or "").strip()
if not name:
return ""
val = os.environ.get(name, "")
if val and val.strip():
return val.strip()
return ""
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=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)})",
msg_zh=f"未知 protocol {p!r}(可选 {' | '.join(PROTOCOLS)})",
)
return p
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] | None:
"""Return model id set, or None if discovery failed after retries (skip validation)."""
import time
base = base_url.rstrip("/")
wire = wire_protocol(protocol)
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,
)
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:
known = KNOWN_DEFAULTS.get(pid, {})
out = {**pconf}
if not out.get("base_url"):
if known.get("base_url"):
out["base_url"] = known["base_url"]
else:
die(f"provider {pid}: base_url required (no default for this id)")
if not out.get("protocol"):
out["protocol"] = known.get("protocol") or "openai"
out["protocol"] = normalize_protocol(out["protocol"])
if not out.get("key"):
die(f"provider {pid}: `key` (free-form secret/env name) is required")
if not isinstance(out.get("models"), list) or not out["models"]:
die(f"provider {pid}: `models` list required (names validated against provider API)")
return out
def main() -> None:
if len(sys.argv) < 3:
die("usage: resolve-providers.py <providers.yaml|json> <out-plan.json> [override-models-csv]")
cfg_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
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()
}
override_items: list[tuple[str | None, str]] = []
if override_csv.strip():
for part in override_csv.split(","):
part = part.strip()
if not part:
continue
head, _, rest = part.partition(":")
if rest and head in providers:
override_items.append((head, rest))
else:
override_items.append((None, part))
requested: list[dict] = []
if override_items:
catalog: dict[str, list[str]] = {}
for pid, pconf in providers.items():
for m in pconf["models"]:
catalog.setdefault(m, []).append(pid)
for prov, model in override_items:
if prov:
requested.append({"provider": prov, "model": model})
else:
hits = catalog.get(model) or []
if len(hits) == 1:
requested.append({"provider": hits[0], "model": model})
elif len(hits) > 1:
die(f"model {model!r} under multiple providers {hits}; use provider:model")
elif len(providers) == 1:
requested.append({"provider": next(iter(providers)), "model": model})
else:
die(f"model {model!r} not in config; use provider:model")
else:
for pid, pconf in providers.items():
for m in pconf["models"]:
requested.append({"provider": pid, "model": m})
seen: set[tuple[str, str]] = set()
uniq: list[dict] = []
for r in requested:
key = (r["provider"], r["model"])
if key not in seen:
seen.add(key)
uniq.append(r)
requested = uniq
if len(requested) < 2:
die(
f"need 2–3 models for parallel review; got {len(requested)}",
msg_zh=f"并行审查至少需要 2–3 个模型,当前只有 {len(requested)} 个",
)
fallback_requested: list[dict] = []
if len(requested) > 3:
print(
f"gatekeep: primary parallel set = first 3 of {len(requested)}; "
f"rest kept as fallbacks on 429/failure",
file=sys.stderr,
)
fallback_requested = requested[3:]
requested = requested[:3]
provider_meta: dict[str, dict] = {}
for pid in {r["provider"] for r in requested + fallback_requested}:
pconf = providers[pid]
key_name = str(pconf["key"])
api_key = secret_value(key_name)
if not api_key:
die(
f"missing secret/env for provider {pid!r}: expected env var named {key_name!r} "
f"(from providers.*.key — free-form; set it on the workflow step env from secrets).",
msg_zh=(
f"缺少供应商 {pid!r} 的密钥:需要环境变量 {key_name!r}"
f"(来自 providers.*.key;请在 workflow step 的 env 里从 secrets 注入)。"
),
)
available = discover_model_ids(pconf["base_url"], pconf["protocol"], api_key)
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",
)
provider_meta[pid] = {
"base_url": pconf["base_url"].rstrip("/"),
"protocol": pconf["protocol"],
"wire_protocol": wire_protocol(pconf["protocol"]),
"key_name": key_name,
"api_key": api_key,
"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 avail is not None and model not in avail:
sample = sorted(avail)[:8]
die(
f"model {model!r} not available on provider {pid} "
f"({provider_meta[pid]['available_count']} listed). Examples: {sample}",
msg_zh=(
f"模型 {model!r} 在供应商 {pid} 上不可用"
f"(列表共 {provider_meta[pid]['available_count']} 个)。示例:{sample}"
),
)
return {
"provider": pid,
"model": model,
"base_url": provider_meta[pid]["base_url"],
"protocol": provider_meta[pid]["protocol"],
"wire_protocol": provider_meta[pid]["wire_protocol"],
"key_name": provider_meta[pid]["key_name"],
}
plan_models = [to_plan_entry(r) for r in requested]
fallback_models = [to_plan_entry(r) for r in fallback_requested]
secrets_path = out_path.with_suffix(".secrets.json")
secrets_path.write_text(
json.dumps({pid: provider_meta[pid]["api_key"] for pid in provider_meta}, indent=2)
)
os.chmod(secrets_path, 0o600)
plan = {
"agreement_min": int(cfg.get("agreement_min", 2)),
"temperature": cfg.get("temperature", 0),
"max_tokens": int(cfg.get("max_tokens", 4096)),
"language": language,
"models": plan_models,
"fallback_models": fallback_models,
"secrets_file": str(secrets_path),
"providers_used": sorted(provider_meta.keys()),
}
out_path.write_text(json.dumps(plan, indent=2))
print(
json.dumps(
{
"providers": plan["providers_used"],
"models": [f"{m['provider']}:{m['model']}" for m in plan_models],
"fallbacks": [f"{m['provider']}:{m['model']}" for m in fallback_models],
"keys": {m["provider"]: m["key_name"] for m in plan_models},
"agreement_min": plan["agreement_min"],
"language": plan["language"],
}
),
flush=True,
)
if __name__ == "__main__":
main()