fix: harden partial 429 success, fallbacks, zh errors
Gatekeep evals / eval-floor (push) Successful in 3s
Gatekeep evals / eval-floor (pull_request) Successful in 15s
Gatekeep / gatekeep (pull_request) Failing after 1m46s

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.
This commit is contained in:
layue13 committed 2026-10-27 08:35:52 +00:00
1 parent ef13f8806a
commit c6c2d071fb
10 files changed
+193 -61

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
# #
# Secret: OPENROUTER_API_KEY (already configured on this repo) # Secret: OPENROUTER_API_KEY (already configured on this repo)
# Action: in-repo composite at repository root (uses: ./) # 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 name: Gatekeep
+2
View File
@@ -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). `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). Allowlists / Gitea-on-github.com limitations: [`docs/remote-uses.md`](docs/remote-uses.md).
## Providers YAML ## Providers YAML
+2
View File
@@ -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)。 `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`。 github.com runner 若不允许非 github.com Action 源,见 `docs/remote-uses.md`。
## 多供应商(providers.yaml) ## 多供应商(providers.yaml)
+10 -2
View File
@@ -58,10 +58,18 @@ runs:
# Runner image / consumer workflow must provide Node >= 18. # Runner image / consumer workflow must provide Node >= 18.
- name: Require Node.js - name: Require Node.js
shell: bash shell: bash
env:
GATEKEEP_LANGUAGE: ${{ inputs.language }}
run: | run: |
if ! command -v node >/dev/null 2>&1; then if ! command -v node >/dev/null 2>&1; then
echo "::error::Node.js >= 18 required for Gatekeep (merge-findings.js / publish.js)." lang="${GATEKEEP_LANGUAGE:-}"
echo "Preinstall Node on the runner, or add a mirrored setup-node step before uses: ./." 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 exit 1
fi fi
node --version node --version
+6 -4
View File
@@ -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 `<!-- gatekeep-review -->`; run 15833 **success** | | Proven | [PR #2](https://git.potatolab.uk/layue13/gatekeep/pulls/2) got `<!-- gatekeep-review -->`; run 15833 **success** |
| Report language | `defaults/providers.yaml` → `language: zh-CN` (override with Action `language:`) | | 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 ## How to see a review
1. Branch off `main`, make a tiny change, open a PR on https://git.potatolab.uk/layue13/gatekeep 1. Branch off **current** `main` (or merge/rebase main into the PR branch — `uses: ./` loads Action code from the PR head)
2. Wait for the **Gatekeep** workflow (same Actions runner pool as other `layue13` repos) 2. Open/push a PR on https://git.potatolab.uk/layue13/gatekeep
3. Look for the summary comment `<!-- gatekeep-review -->` on the PR 3. Wait for the **Gatekeep** workflow (same Actions runner pool as other `layue13` repos)
4. Look for the summary comment `<!-- gatekeep-review -->` (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/`. Consumer sample workflows remain under top-level `workflows/` for copy-paste into *other* repos’ `.github/workflows/`.
Regular → Executable
+31 -21
View File
@@ -2,6 +2,7 @@
# Call one model via provider protocol. # Call one model via provider protocol.
# Protocols: openai | openai-completions | openai-responses | anthropic # Protocols: openai | openai-completions | openai-responses | anthropic
# (openai / openai-compatible → chat completions) # (openai / openai-compatible → chat completions)
# Retries HTTP 429 with exponential backoff (GATEKEEP_HTTP_RETRIES, default 3).
# Args: <provider> <model> <protocol> <base_url> <prompt.json> <out.json> <secrets.json> # Args: <provider> <model> <protocol> <base_url> <prompt.json> <out.json> <secrets.json>
set -euo pipefail set -euo pipefail
@@ -21,6 +22,7 @@ if [[ -z "$KEY" ]]; then
fi fi
MAX_TOKENS="${GATEKEEP_MAX_TOKENS:-4096}" 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") 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") USER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user"])' "$PROMPT_FILE")
@@ -54,13 +56,9 @@ print(json.dumps({
})) }))
PY PY
) )
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ URL="${BASE_URL%/}/chat/completions"
"${BASE_URL%/}/chat/completions" \ EXTRA_HDR=(-H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY:-gatekeep}" -H "X-Title: Gatekeep")
-H "Authorization: Bearer $KEY" \ AUTH=(-H "Authorization: Bearer $KEY")
-H "Content-Type: application/json" \
-H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY:-gatekeep}" \
-H "X-Title: Gatekeep" \
-d "$BODY")
EXTRACT=openai EXTRACT=openai
elif [[ "$WIRE" == "openai-responses" ]]; then elif [[ "$WIRE" == "openai-responses" ]]; then
BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY' BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY'
@@ -77,11 +75,9 @@ print(json.dumps({
})) }))
PY PY
) )
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ URL="${BASE_URL%/}/responses"
"${BASE_URL%/}/responses" \ EXTRA_HDR=()
-H "Authorization: Bearer $KEY" \ AUTH=(-H "Authorization: Bearer $KEY")
-H "Content-Type: application/json" \
-d "$BODY")
EXTRACT=openai-responses EXTRACT=openai-responses
elif [[ "$WIRE" == "anthropic" ]]; then elif [[ "$WIRE" == "anthropic" ]]; then
BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY' BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY'
@@ -96,21 +92,36 @@ print(json.dumps({
})) }))
PY PY
) )
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \ URL="${BASE_URL%/}/v1/messages"
"${BASE_URL%/}/v1/messages" \ EXTRA_HDR=(-H "anthropic-version: 2023-06-01")
-H "x-api-key: $KEY" \ AUTH=(-H "x-api-key: $KEY")
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d "$BODY")
EXTRACT=anthropic EXTRACT=anthropic
fi 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 echo "$LABEL failed HTTP $HTTP_CODE" >&2
head -c 800 "$OUT_FILE.raw" >&2 || true head -c 800 "$OUT_FILE.raw" >&2 || true
echo "{\"error\":\"http\",\"model\":\"$MODEL\",\"provider\":\"$PROVIDER\",\"http\":$HTTP_CODE}" > "$OUT_FILE" echo "{\"error\":\"http\",\"model\":\"$MODEL\",\"provider\":\"$PROVIDER\",\"http\":$HTTP_CODE}" > "$OUT_FILE"
exit 0 exit 0
fi done
python3 - "$OUT_FILE.raw" "$OUT_FILE" "$MODEL" "$PROVIDER" "$EXTRACT" <<'PY' python3 - "$OUT_FILE.raw" "$OUT_FILE" "$MODEL" "$PROVIDER" "$EXTRACT" <<'PY'
import json,sys import json,sys
@@ -123,7 +134,6 @@ if kind == "anthropic":
if c.get("type") == "text" if c.get("type") == "text"
) )
elif kind == "openai-responses": elif kind == "openai-responses":
# Best-effort extract from Responses API
text = raw.get("output_text") or "" text = raw.get("output_text") or ""
if not text: if not text:
chunks = [] chunks = []
+15 -5
View File
@@ -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 agreed = [];
const disputed = []; const disputed = [];
for (const f of bucket.values()) { 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); else disputed.push(f);
} }
@@ -178,8 +190,6 @@ const bySeverity = (a, b) => (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ?
agreed.sort(bySeverity); agreed.sort(bySeverity);
disputed.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; const tierSources = okModels.length ? okModels : models;
let risk_tier = floor; let risk_tier = floor;
for (const m of tierSources) risk_tier = maxTier(risk_tier, m.risk_tier); 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 = const openSecurityBlocking =
scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking); scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking);
const modelErrors = models.filter((m) => m.error).length;
const result = { const result = {
risk_tier, risk_tier,
policy_floor: floor, policy_floor: floor,
agreement_min: agreementMin, agreement_min: agreementMin,
effective_agreement_min: effectiveAgreement,
language, language,
degraded,
model_errors: modelErrors, model_errors: modelErrors,
models_ok: okModels.length, models_ok: okModels.length,
models: models.map((m) => ({ models: models.map((m) => ({
+22 -7
View File
@@ -81,8 +81,13 @@ const T = zh
"看起来风险低 — Gatekeep + 安全绿灯后可不需人工(仅当 kill switch 打开才可自动合并)。", "看起来风险低 — Gatekeep + 安全绿灯后可不需人工(仅当 kill switch 打开才可自动合并)。",
approveMed: "需要 **1 名人工**过目。Bot 点赞不算人工。", approveMed: "需要 **1 名人工**过目。Bot 点赞不算人工。",
approveHigh: "需要 **2 名人工**批准。Bot 不能顶替。", approveHigh: "需要 **2 名人工**批准。Bot 不能顶替。",
errNote: (n, ok) => errNote: (n, ok, degAgree) =>
`> **说明:** ${n} 个模型调用失败(常见于免费档限流 429)。风险档按**失败关闭**规则取高,**不等于**代码一定有问题。成功响应:${ok} 个。`, `> **降级说明:** ${n} 个模型调用失败(常见免费档限流 429;已重试/尝试备用模型)。` +
(ok === 0
? `全部失败 → 风险档**失败关闭为 high**,**不等于**代码一定有问题。`
: `已用 ${ok} 个成功响应继续审查` +
(degAgree ? `(一致门槛临时降为 ${degAgree})` : "") +
`;失败关闭的 high **仅在零成功响应时**强制抬升。`),
statusBlock: "存在未关闭的安全 / Critical 结论 — 合并阻断", statusBlock: "存在未关闭的安全 / Critical 结论 — 合并阻断",
statusOk: (tier) => `PR 风险 ${tier} — 无未关闭安全阻断`, statusOk: (tier) => `PR 风险 ${tier} — 无未关闭安全阻断`,
modelsAgree: "一致门槛 ≥", 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).", "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.", approveMed: "Needs **1 human** look. Bot thumbs-up don't count as that look.",
approveHigh: "Needs **2 human** approvals. Bots never fill those seats.", approveHigh: "Needs **2 human** approvals. Bots never fill those seats.",
errNote: (n, ok) => errNote: (n, ok, degAgree) =>
`> **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}.`, `> **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", statusBlock: "Open security / Critical findings — merge blocked",
statusOk: (tier) => `PR risk ${tier} — no open security blockers`, statusOk: (tier) => `PR risk ${tier} — no open security blockers`,
modelsAgree: "≥", modelsAgree: "≥",
@@ -236,11 +246,16 @@ const okCount = Number(
? merged.models_ok ? merged.models_ok
: (merged.models || []).filter((m) => !m.error).length, : (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 const agreementBar = zh
? `${T.modelsAgree}${merged.agreement_min} 个模型` ? `${T.modelsAgree}${merged.agreement_min} 个模型` +
: `≥${merged.agreement_min} models`; (degAgree ? `(本轮降为 ${degAgree})` : "")
: `≥${merged.agreement_min} models` + (degAgree ? ` (this run: ${degAgree})` : "");
const body = `${marker} const body = `${marker}
${T.title} ${T.title}
+52 -21
View File
@@ -46,8 +46,14 @@ KNOWN_DEFAULTS = {
} }
def die(msg: str, code: int = 1) -> None: def _zh() -> bool:
print(f"gatekeep: {msg}", file=sys.stderr) 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) sys.exit(code)
@@ -57,7 +63,10 @@ def load_raw(path: Path) -> dict:
try: try:
import yaml # type: ignore import yaml # type: ignore
except ImportError: 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 {} raw = yaml.safe_load(text) or {}
else: else:
raw = json.loads(text) raw = json.loads(text)
@@ -240,24 +249,40 @@ def main() -> None:
requested = uniq requested = uniq
if len(requested) < 2: 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: 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] requested = requested[:3]
provider_meta: dict[str, dict] = {} 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] pconf = providers[pid]
key_name = str(pconf["key"]) key_name = str(pconf["key"])
api_key = secret_value(key_name) api_key = secret_value(key_name)
if not api_key: if not api_key:
die( die(
f"missing secret/env for provider {pid!r}: expected env var named {key_name!r} " 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) available = discover_model_ids(pconf["base_url"], pconf["protocol"], api_key)
if not available: 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] = { provider_meta[pid] = {
"base_url": pconf["base_url"].rstrip("/"), "base_url": pconf["base_url"].rstrip("/"),
"protocol": pconf["protocol"], "protocol": pconf["protocol"],
@@ -268,26 +293,30 @@ def main() -> None:
"_available": available, "_available": available,
} }
plan_models = [] def to_plan_entry(r: dict) -> dict:
for r in requested:
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 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} "
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( return {
{ "provider": pid,
"provider": pid, "model": model,
"model": model, "base_url": provider_meta[pid]["base_url"],
"base_url": provider_meta[pid]["base_url"], "protocol": provider_meta[pid]["protocol"],
"protocol": provider_meta[pid]["protocol"], "wire_protocol": provider_meta[pid]["wire_protocol"],
"wire_protocol": provider_meta[pid]["wire_protocol"], "key_name": provider_meta[pid]["key_name"],
"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 = out_path.with_suffix(".secrets.json")
secrets_path.write_text( secrets_path.write_text(
@@ -306,6 +335,7 @@ def main() -> None:
"max_tokens": int(cfg.get("max_tokens", 4096)), "max_tokens": int(cfg.get("max_tokens", 4096)),
"language": language, "language": language,
"models": plan_models, "models": plan_models,
"fallback_models": fallback_models,
"secrets_file": str(secrets_path), "secrets_file": str(secrets_path),
"providers_used": sorted(provider_meta.keys()), "providers_used": sorted(provider_meta.keys()),
} }
@@ -315,6 +345,7 @@ def main() -> None:
{ {
"providers": plan["providers_used"], "providers": plan["providers_used"],
"models": [f"{m['provider']}:{m['model']}" for m in plan_models], "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}, "keys": {m["provider"]: m["key_name"] for m in plan_models},
"agreement_min": plan["agreement_min"], "agreement_min": plan["agreement_min"],
"language": plan["language"], "language": plan["language"],
Regular → Executable
+47 -1
View File
@@ -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) print(f"Prompt ready; floor={floor}; language={language}; plan={len(plan.get('models',[]))} models", flush=True)
PY PY
# Fire models in parallel (may be mixed providers) # Fire primary models in parallel (may be mixed providers)
pids=() pids=()
i=0 i=0
while IFS= read -r line; do while IFS= read -r line; do
@@ -173,6 +173,52 @@ for pid in "${pids[@]}"; do
done done
echo "Parallel model calls finished (wait_ec=$ec)" >&2 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 # Scrub secrets file from workspace artifact path if present
rm -f "$SECRETS_FILE" rm -f "$SECRETS_FILE"