From c6c2d071fb540b6e61db0bd8be929e49753db4ec Mon Sep 17 00:00:00 2001 From: layue13 Date: Tue, 27 Oct 2026 08:35:52 +0000 Subject: [PATCH] fix: harden partial 429 success, fallbacks, zh errors Retry HTTP 429 with backoff; keep models beyond first 3 as fallbacks; degrade agreement when some models fail; force-high risk only if zero usable responses. Clearer zh-CN errors/docs for dogfood uses: ./ rebase + workflow_dispatch re-run. --- .github/workflows/gatekeep-pr.yml | 6 +++ README.md | 2 + README.zh-CN.md | 2 + action.yml | 12 ++++- docs/dogfood.md | 10 +++-- scripts/call-model.sh | 52 +++++++++++++--------- scripts/merge-findings.js | 20 ++++++--- scripts/publish.js | 29 +++++++++--- scripts/resolve-providers.py | 73 ++++++++++++++++++++++--------- scripts/review.sh | 48 +++++++++++++++++++- 10 files changed, 193 insertions(+), 61 deletions(-) mode change 100644 => 100755 scripts/call-model.sh mode change 100644 => 100755 scripts/review.sh diff --git a/.github/workflows/gatekeep-pr.yml b/.github/workflows/gatekeep-pr.yml index d94708c..f47769a 100644 --- a/.github/workflows/gatekeep-pr.yml +++ b/.github/workflows/gatekeep-pr.yml @@ -3,6 +3,12 @@ # # Secret: OPENROUTER_API_KEY (already configured on this repo) # Action: in-repo composite at repository root (uses: ./) +# +# Important (uses: ./): +# - Checkout is the PR head, so Action fixes on main do NOT apply until the +# PR branch rebases/merges main. Stale heads can look "broken" after a fix. +# - Re-run: push an empty commit, or Actions → this workflow → Run workflow +# (workflow_dispatch; uses default branch / manual inputs as configured). name: Gatekeep diff --git a/README.md b/README.md index 94c9fb1..1233e41 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ AI-assisted PR review for **your own repos**: parallel multi-model review across `layue13/gatekeep` reviews **its own** PRs via [`.github/workflows/gatekeep-pr.yml`](.github/workflows/gatekeep-pr.yml) with `uses: ./` and `secrets.OPENROUTER_API_KEY`. Open a PR against `main` to see a review (Gitea Actions must have a runner — same pool as other `layue13` repos). +Because dogfood uses `uses: ./`, **rebase/merge `main` into the PR branch** after Action fixes land, or the PR still runs the old Action. Re-run via new push or **Actions → Gatekeep → Run workflow**. Free-model 429s are retried; extras in `models:` after the first 3 are fallbacks; risk is force-high only if **zero** models succeed. + Allowlists / Gitea-on-github.com limitations: [`docs/remote-uses.md`](docs/remote-uses.md). ## Providers YAML diff --git a/README.zh-CN.md b/README.zh-CN.md index 32a81fb..764bdc5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,6 +28,8 @@ AI 辅助的 PR 审查 Action:多模型并行投票、按严重级别输出可 `layue13/gatekeep` 用 [`.github/workflows/gatekeep-pr.yml`](.github/workflows/gatekeep-pr.yml) 对本仓库 PR 跑 Gatekeep:`uses: ./` + `secrets.OPENROUTER_API_KEY`。向 `main` 开 PR 即可触发(需有可用的 Gitea Actions runner)。 +**注意:** `uses: ./` 用的是 **PR 分支上的 Action**。main 上的修复要进 PR,需要 rebase/merge main,否则看起来像“修了还挂”。重跑:再推一次提交,或 Actions → Gatekeep → **Run workflow**。免费模型 429 会退避重试;`models` 列表第 4 个及以后作备用;**只有全部模型失败**才强制抬高风险档为 high。 + github.com runner 若不允许非 github.com Action 源,见 `docs/remote-uses.md`。 ## 多供应商(providers.yaml) diff --git a/action.yml b/action.yml index 2053daa..31b8053 100644 --- a/action.yml +++ b/action.yml @@ -58,10 +58,18 @@ runs: # Runner image / consumer workflow must provide Node >= 18. - name: Require Node.js shell: bash + env: + GATEKEEP_LANGUAGE: ${{ inputs.language }} run: | if ! command -v node >/dev/null 2>&1; then - echo "::error::Node.js >= 18 required for Gatekeep (merge-findings.js / publish.js)." - echo "Preinstall Node on the runner, or add a mirrored setup-node step before uses: ./." + lang="${GATEKEEP_LANGUAGE:-}" + if [[ "${lang,,}" == zh* ]]; then + echo "::error::Gatekeep 需要 Node.js ≥ 18(merge-findings.js / publish.js)。" + echo "请在 runner 预装 Node,或在 uses: ./ 之前加本 forge 已镜像的 setup-node;不要依赖未镜像的 actions/setup-node。" + else + echo "::error::Node.js >= 18 required for Gatekeep (merge-findings.js / publish.js)." + echo "Preinstall Node on the runner, or add a mirrored setup-node step before uses: ./." + fi exit 1 fi node --version diff --git a/docs/dogfood.md b/docs/dogfood.md index 0b561b0..25d3bfa 100644 --- a/docs/dogfood.md +++ b/docs/dogfood.md @@ -11,12 +11,14 @@ The Action repo itself runs Gatekeep on its own pull requests. | Proven | [PR #2](https://git.potatolab.uk/layue13/gatekeep/pulls/2) got ``; run 15833 **success** | | Report language | `defaults/providers.yaml` → `language: zh-CN` (override with Action `language:`) | -Gitea notes: no `actions/setup-node` mirror — Action requires runner Node ≥18. PyYAML installs via workspace venv (PEP 668). Free OpenRouter models may 429; summary softens that messaging and prefers cohere/nvidia in the first-3 set. +Gitea notes: no `actions/setup-node` mirror — Action requires runner Node ≥18. PyYAML installs via workspace venv (PEP 668). Free OpenRouter models may 429: Action retries with backoff, tries `fallback_models` (list entries after the first 3), and only force-elevates risk to high when **zero** models succeed. Summary is Chinese when `language: zh-CN`. ## How to see a review -1. Branch off `main`, make a tiny change, open a PR on https://git.potatolab.uk/layue13/gatekeep -2. Wait for the **Gatekeep** workflow (same Actions runner pool as other `layue13` repos) -3. Look for the summary comment `` on the PR +1. Branch off **current** `main` (or merge/rebase main into the PR branch — `uses: ./` loads Action code from the PR head) +2. Open/push a PR on https://git.potatolab.uk/layue13/gatekeep +3. Wait for the **Gatekeep** workflow (same Actions runner pool as other `layue13` repos) +4. Look for the summary comment `` (updated in place on re-run) +5. Manual re-run: Actions → Gatekeep → **Run workflow** (`workflow_dispatch`), or push another commit Consumer sample workflows remain under top-level `workflows/` for copy-paste into *other* repos’ `.github/workflows/`. diff --git a/scripts/call-model.sh b/scripts/call-model.sh old mode 100644 new mode 100755 index 275bde7..d1dbb80 --- a/scripts/call-model.sh +++ b/scripts/call-model.sh @@ -2,6 +2,7 @@ # Call one model via provider protocol. # Protocols: openai | openai-completions | openai-responses | anthropic # (openai / openai-compatible → chat completions) +# Retries HTTP 429 with exponential backoff (GATEKEEP_HTTP_RETRIES, default 3). # Args: set -euo pipefail @@ -21,6 +22,7 @@ if [[ -z "$KEY" ]]; then fi MAX_TOKENS="${GATEKEEP_MAX_TOKENS:-4096}" +RETRIES="${GATEKEEP_HTTP_RETRIES:-3}" SYSTEM=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["system"])' "$PROMPT_FILE") USER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user"])' "$PROMPT_FILE") @@ -54,13 +56,9 @@ print(json.dumps({ })) PY ) - HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ - "${BASE_URL%/}/chat/completions" \ - -H "Authorization: Bearer $KEY" \ - -H "Content-Type: application/json" \ - -H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY:-gatekeep}" \ - -H "X-Title: Gatekeep" \ - -d "$BODY") + URL="${BASE_URL%/}/chat/completions" + EXTRA_HDR=(-H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY:-gatekeep}" -H "X-Title: Gatekeep") + AUTH=(-H "Authorization: Bearer $KEY") EXTRACT=openai elif [[ "$WIRE" == "openai-responses" ]]; then BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY' @@ -77,11 +75,9 @@ print(json.dumps({ })) PY ) - HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ - "${BASE_URL%/}/responses" \ - -H "Authorization: Bearer $KEY" \ - -H "Content-Type: application/json" \ - -d "$BODY") + URL="${BASE_URL%/}/responses" + EXTRA_HDR=() + AUTH=(-H "Authorization: Bearer $KEY") EXTRACT=openai-responses elif [[ "$WIRE" == "anthropic" ]]; then BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY' @@ -96,21 +92,36 @@ print(json.dumps({ })) PY ) - HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ - "${BASE_URL%/}/v1/messages" \ - -H "x-api-key: $KEY" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d "$BODY") + URL="${BASE_URL%/}/v1/messages" + EXTRA_HDR=(-H "anthropic-version: 2023-06-01") + AUTH=(-H "x-api-key: $KEY") EXTRACT=anthropic fi -if [[ "$HTTP_CODE" != "200" ]]; then +attempt=0 +HTTP_CODE="000" +while true; do + HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ + "$URL" \ + "${AUTH[@]}" \ + -H "Content-Type: application/json" \ + "${EXTRA_HDR[@]}" \ + -d "$BODY" || echo "000") + if [[ "$HTTP_CODE" == "200" ]]; then + break + fi + if [[ "$HTTP_CODE" == "429" && "$attempt" -lt "$RETRIES" ]]; then + sleep_s=$((2 ** attempt)) + echo "$LABEL HTTP 429 — retry $((attempt + 1))/${RETRIES} after ${sleep_s}s" >&2 + sleep "$sleep_s" + attempt=$((attempt + 1)) + continue + fi echo "$LABEL failed HTTP $HTTP_CODE" >&2 head -c 800 "$OUT_FILE.raw" >&2 || true echo "{\"error\":\"http\",\"model\":\"$MODEL\",\"provider\":\"$PROVIDER\",\"http\":$HTTP_CODE}" > "$OUT_FILE" exit 0 -fi +done python3 - "$OUT_FILE.raw" "$OUT_FILE" "$MODEL" "$PROVIDER" "$EXTRACT" <<'PY' import json,sys @@ -123,7 +134,6 @@ if kind == "anthropic": if c.get("type") == "text" ) elif kind == "openai-responses": - # Best-effort extract from Responses API text = raw.get("output_text") or "" if not text: chunks = [] diff --git a/scripts/merge-findings.js b/scripts/merge-findings.js index b62e041..0a1ed77 100644 --- a/scripts/merge-findings.js +++ b/scripts/merge-findings.js @@ -167,10 +167,22 @@ for (const m of models) { } } +// Prefer successful model tiers; only if every call failed, fail-closed high. +const okModels = models.filter((m) => !m.error); +const modelErrors = models.filter((m) => m.error).length; +const degraded = modelErrors > 0 || okModels.length < agreementMin; + +// When some models 429/fail, lower the bar to surface usable findings from survivors +// (still require at least 1 successful model). Full fail-closed high only if zero ok. +let effectiveAgreement = agreementMin; +if (okModels.length > 0 && okModels.length < agreementMin) { + effectiveAgreement = Math.max(1, okModels.length); +} + const agreed = []; const disputed = []; for (const f of bucket.values()) { - if (f.models.length >= agreementMin) agreed.push(f); + if (f.models.length >= effectiveAgreement) agreed.push(f); else disputed.push(f); } @@ -178,8 +190,6 @@ const bySeverity = (a, b) => (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ? agreed.sort(bySeverity); disputed.sort(bySeverity); -// Prefer successful model tiers; only if every call failed, fail-closed high. -const okModels = models.filter((m) => !m.error); const tierSources = okModels.length ? okModels : models; let risk_tier = floor; for (const m of tierSources) risk_tier = maxTier(risk_tier, m.risk_tier); @@ -193,13 +203,13 @@ if (scanHardFail) risk_tier = maxTier(risk_tier, "high"); const openSecurityBlocking = scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking); -const modelErrors = models.filter((m) => m.error).length; - const result = { risk_tier, policy_floor: floor, agreement_min: agreementMin, + effective_agreement_min: effectiveAgreement, language, + degraded, model_errors: modelErrors, models_ok: okModels.length, models: models.map((m) => ({ diff --git a/scripts/publish.js b/scripts/publish.js index 8df570e..6bac033 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -81,8 +81,13 @@ const T = zh "看起来风险低 — Gatekeep + 安全绿灯后可不需人工(仅当 kill switch 打开才可自动合并)。", approveMed: "需要 **1 名人工**过目。Bot 点赞不算人工。", approveHigh: "需要 **2 名人工**批准。Bot 不能顶替。", - errNote: (n, ok) => - `> **说明:** ${n} 个模型调用失败(常见于免费档限流 429)。风险档按**失败关闭**规则取高,**不等于**代码一定有问题。成功响应:${ok} 个。`, + errNote: (n, ok, degAgree) => + `> **降级说明:** ${n} 个模型调用失败(常见免费档限流 429;已重试/尝试备用模型)。` + + (ok === 0 + ? `全部失败 → 风险档**失败关闭为 high**,**不等于**代码一定有问题。` + : `已用 ${ok} 个成功响应继续审查` + + (degAgree ? `(一致门槛临时降为 ${degAgree})` : "") + + `;失败关闭的 high **仅在零成功响应时**强制抬升。`), statusBlock: "存在未关闭的安全 / Critical 结论 — 合并阻断", statusOk: (tier) => `PR 风险 ${tier} — 无未关闭安全阻断`, modelsAgree: "一致门槛 ≥", @@ -135,8 +140,13 @@ const T = zh "Looks low-risk — no human needed once Gatekeep + security are green (auto-merge only if the kill switch is on).", approveMed: "Needs **1 human** look. Bot thumbs-up don't count as that look.", approveHigh: "Needs **2 human** approvals. Bots never fill those seats.", - errNote: (n, ok) => - `> **Note:** ${n} model call(s) failed (often free-tier 429). Risk tier is **fail-closed** high when calls fail — that does **not** mean the code is necessarily bad. Successful responses: ${ok}.`, + errNote: (n, ok, degAgree) => + `> **Degraded:** ${n} model call(s) failed (often free-tier 429; retried / tried fallbacks). ` + + (ok === 0 + ? `Zero usable responses → risk tier **fail-closed high** (not proof the code is bad).` + : `Reviewing from ${ok} successful response(s)` + + (degAgree ? ` (agreement bar temporarily ${degAgree})` : "") + + `; fail-closed high is forced only when **zero** models succeed.`), statusBlock: "Open security / Critical findings — merge blocked", statusOk: (tier) => `PR risk ${tier} — no open security blockers`, modelsAgree: "≥", @@ -236,11 +246,16 @@ const okCount = Number( ? merged.models_ok : (merged.models || []).filter((m) => !m.error).length, ); -const errBanner = errCount > 0 ? `\n${T.errNote(errCount, okCount)}\n` : ""; +const effAgree = merged.effective_agreement_min; +const degAgree = + merged.degraded && effAgree != null && effAgree < merged.agreement_min ? effAgree : null; +const errBanner = + errCount > 0 || merged.degraded ? `\n${T.errNote(errCount, okCount, degAgree)}\n` : ""; const agreementBar = zh - ? `${T.modelsAgree}${merged.agreement_min} 个模型` - : `≥${merged.agreement_min} models`; + ? `${T.modelsAgree}${merged.agreement_min} 个模型` + + (degAgree ? `(本轮降为 ${degAgree})` : "") + : `≥${merged.agreement_min} models` + (degAgree ? ` (this run: ${degAgree})` : ""); const body = `${marker} ${T.title} diff --git a/scripts/resolve-providers.py b/scripts/resolve-providers.py index 46799f6..fdd21fd 100644 --- a/scripts/resolve-providers.py +++ b/scripts/resolve-providers.py @@ -46,8 +46,14 @@ KNOWN_DEFAULTS = { } -def die(msg: str, code: int = 1) -> None: - print(f"gatekeep: {msg}", file=sys.stderr) +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) @@ -57,7 +63,10 @@ def load_raw(path: Path) -> dict: try: import yaml # type: ignore except ImportError: - die("PyYAML required to load providers.yaml (pip install pyyaml)") + 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) @@ -240,24 +249,40 @@ def main() -> None: requested = uniq if len(requested) < 2: - die(f"need 2–3 models for parallel review; got {len(requested)}") + 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: truncating to first 3 of {len(requested)} models", file=sys.stderr) + 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}: + 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)." + 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 not available: - die(f"provider {pid}: model list empty — check base_url/protocol/key") + 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"], @@ -268,26 +293,30 @@ def main() -> None: "_available": available, } - plan_models = [] - for r in requested: + def to_plan_entry(r: dict) -> dict: pid, model = r["provider"], r["model"] avail = provider_meta[pid]["_available"] if 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}" + f"({provider_meta[pid]['available_count']} listed). Examples: {sample}", + msg_zh=( + f"模型 {model!r} 在供应商 {pid} 上不可用" + f"(列表共 {provider_meta[pid]['available_count']} 个)。示例:{sample}" + ), ) - plan_models.append( - { - "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"], - } - ) + 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( @@ -306,6 +335,7 @@ def main() -> None: "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()), } @@ -315,6 +345,7 @@ def main() -> None: { "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"], diff --git a/scripts/review.sh b/scripts/review.sh old mode 100644 new mode 100755 index c6dd8b5..43d9263 --- a/scripts/review.sh +++ b/scripts/review.sh @@ -151,7 +151,7 @@ json.dump({"system": system, "user": json.dumps(user, indent=2), "language": lan print(f"Prompt ready; floor={floor}; language={language}; plan={len(plan.get('models',[]))} models", flush=True) PY -# Fire models in parallel (may be mixed providers) +# Fire primary models in parallel (may be mixed providers) pids=() i=0 while IFS= read -r line; do @@ -173,6 +173,52 @@ for pid in "${pids[@]}"; do done echo "Parallel model calls finished (wait_ec=$ec)" >&2 +# On partial failure, try fallback_models from providers.yaml (beyond the first 3) +python3 - "$MODELS_DIR" "$PLAN" <<'PY' >"$OUT/fallback-needed.txt" +import json, pathlib, sys +models_dir = pathlib.Path(sys.argv[1]) +plan = json.load(open(sys.argv[2])) +failed = ok = 0 +for p in models_dir.glob("model-*.json"): + try: + j = json.loads(p.read_text()) + except Exception: + failed += 1 + continue + if j.get("error") or j.get("parse_error"): + failed += 1 + else: + ok += 1 +fallbacks = plan.get("fallback_models") or [] +# Aim for ≥2 usable responses; if zero ok, try up to 3 reserves +if ok == 0: + need = min(len(fallbacks), 3) +elif ok < 2: + need = min(len(fallbacks), 2 - ok) +else: + need = 0 +print(need) +print(f"gatekeep: primary ok={ok} failed={failed} fallbacks={len(fallbacks)} need={need}", file=sys.stderr) +PY +NEED_FB=$(head -1 "$OUT/fallback-needed.txt" | tr -d '[:space:]') +NEED_FB=${NEED_FB:-0} +if [[ "$NEED_FB" -gt 0 ]]; then + echo "gatekeep: trying $NEED_FB fallback model(s) after partial failure" >&2 + fb_i=0 + while IFS= read -r line; do + fb_i=$((fb_i + 1)) + [[ "$fb_i" -gt "$NEED_FB" ]] && break + provider=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["provider"])') + model=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["model"])') + protocol=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["protocol"])') + base_url=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["base_url"])') + safe=$(echo "fb_${provider}_${model}" | tr '/:' '__') + bash "$ACTION/scripts/call-model.sh" \ + "$provider" "$model" "$protocol" "$base_url" \ + "$OUT/prompt.json" "$MODELS_DIR/model-fb$fb_i-$safe.json" "$SECRETS_FILE" || true + done < <(python3 -c 'import json,sys; [print(json.dumps(m)) for m in json.load(open(sys.argv[1])).get("fallback_models") or []]' "$PLAN") +fi + # Scrub secrets file from workspace artifact path if present rm -f "$SECRETS_FILE"