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",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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:
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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")
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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:
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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)
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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
|
|
|
|
)
|
2026-10-27 08:47:12 +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)
|
2026-10-27 08:47:12 +00:00
|
|
|
|
# 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:
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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:
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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] = {}
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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} "
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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)
|
2026-10-27 08:47:12 +00:00
|
|
|
|
if available is not None and len(available) == 0:
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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,
|
2026-10-27 08:47:12 +00:00
|
|
|
|
"available_count": len(available) if available is not None else -1,
|
|
|
|
|
|
"_available": available, # None ⇒ skip validation
|
2026-10-26 20:40:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 08:35:52 +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"]
|
2026-10-27 08:47:12 +00:00
|
|
|
|
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} "
|
2026-10-27 08:35:52 +00:00
|
|
|
|
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
|
|
|
|
)
|
2026-10-27 08:35:52 +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)),
|
2026-10-27 08:29:48 +00:00
|
|
|
|
"language": language,
|
2026-10-26 20:40:51 +00:00
|
|
|
|
"models": plan_models,
|
2026-10-27 08:35:52 +00:00
|
|
|
|
"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],
|
2026-10-27 08:35:52 +00:00
|
|
|
|
"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"],
|
2026-10-27 08:29:48 +00:00
|
|
|
|
"language": plan["language"],
|
2026-10-26 20:40:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
flush=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|