Files

394 lines
14 KiB
Python
Raw Permalink Normal View History

2026-10-26 20:40:51 +00:00
#!/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)
2026-10-26 20:40:51 +00:00
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 步骤)",
)
2026-10-26 20:40:51 +00:00
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:
2026-10-26 20:40:51 +00:00
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
2026-10-26 20:40:51 +00:00
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)})",
)
2026-10-26 20:40:51 +00:00
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
2026-10-26 20:40:51 +00:00
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,
2026-10-26 20:40:51 +00:00
)
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
2026-10-26 20:40:51 +00:00
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
2026-10-26 20:40:51 +00:00
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] = []
2026-10-26 20:40:51 +00:00
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:]
2026-10-26 20:40:51 +00:00
requested = requested[:3]
provider_meta: dict[str, dict] = {}
for pid in {r["provider"] for r in requested + fallback_requested}:
2026-10-26 20:40:51 +00:00
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 注入)。"
),
2026-10-26 20:40:51 +00:00
)
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",
)
2026-10-26 20:40:51 +00:00
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
2026-10-26 20:40:51 +00:00
}
def to_plan_entry(r: dict) -> dict:
2026-10-26 20:40:51 +00:00
pid, model = r["provider"], r["model"]
avail = provider_meta[pid]["_available"]
if avail is not None and model not in avail:
2026-10-26 20:40:51 +00:00
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}"
),
2026-10-26 20:40:51 +00:00
)
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]
2026-10-26 20:40:51 +00:00
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,
2026-10-26 20:40:51 +00:00
"models": plan_models,
"fallback_models": fallback_models,
2026-10-26 20:40:51 +00:00
"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],
2026-10-26 20:40:51 +00:00
"keys": {m["provider"]: m["key_name"] for m in plan_models},
"agreement_min": plan["agreement_min"],
"language": plan["language"],
2026-10-26 20:40:51 +00:00
}
),
flush=True,
)
if __name__ == "__main__":
main()