Skip to content

Commit 5ff702c

Browse files
ZhenghuaBaoclaude
andauthored
fix: react on the default token, and let the recipe pick the judge model (#42)
Two things, both measured on a real run rather than reasoned about. REACTIONS NEVER HAPPENED. The gate read `gh api user --jq .type` and declined unless it saw "Bot". That endpoint needs a user-scoped token, and github-token defaults to ${{ github.token }} — an installation token, for which it returns no user. The `|| echo "Bot"` fallback was meant to cover that and did not: the call can exit 0 with no `.type`, so ME_TYPE came back empty, `!= "Bot"` held, and both the 👀 and the settle step skipped — for every consumer on the default token, which is the case the feature exists to serve. A run logged "github-token belongs to a user, not an app" while the reaction already on the PR was authored by github-actions[bot]. Inverted to fail towards acting: a PAT identifies itself as type User and declines; 403, empty and Bot all react. That also unsticks the 👀 left behind by runs that predate the settle step, since the clear now runs. THE JUDGE MODEL COMES FROM THE RECIPE. judge-model defaulted to a concrete model, so the L2 call named it directly and never reached the router — the one routing decision in the product that a workspace could not make for itself. Its default is now empty, which sends the router alias, and judge.mjs stamps `x-cr-lens: judge` so the recipe's judge rule chooses. No model change: the shipped recipe routes that rule to deepseek-v4-pro, which is what judge-model used to name. Setting judge-model explicitly still pins a model outright, regardless of the recipe. The header goes out unconditionally, including when --model names a concrete model: nothing resolves an alias then, so nothing reads it, and "is this an alias" is the gateway's judgement rather than this script's. ORDER: OrcaRouter-O2#1433 puts the judge rule in the provisioned recipe and has to be deployed first. Without it the judge lands on the recipe's default — the model it is scoring — which agrees with itself while still reporting success. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9f7e019 commit 5ff702c

3 files changed

Lines changed: 74 additions & 9 deletions

File tree

action.yml

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ inputs:
157157
model so the judge acts as an independent second opinion. Ignored when
158158
`precision-filter` is `"false"`.
159159
required: false
160-
default: "deepseek/deepseek-v4-pro"
160+
default: ""
161161
judge-threshold:
162162
description: >-
163163
Keep-threshold for the L2 judge's per-cluster confidence score (0–1).
@@ -286,8 +286,19 @@ runs:
286286
#
287287
# Checked once here rather than inside react(), so a token that cannot be
288288
# identified costs one API call, not two.
289-
ME_TYPE=$(gh api user --jq .type 2>/dev/null || echo "Bot")
290-
if [ "$ME_TYPE" != "Bot" ]; then
289+
# ONLY AN EXPLICIT "User" DECLINES. `gh api user` needs a user-scoped
290+
# token, and github-token defaults to ${{ github.token }} — an
291+
# installation token, for which that endpoint returns no user. The old
292+
# `|| echo "Bot"` fallback was meant to cover that and did not: the call
293+
# can exit 0 with no `.type`, so ME_TYPE came back empty, `!= "Bot"` held,
294+
# and the reaction was skipped for EVERY consumer on the default token —
295+
# the case this exists to serve. Measured on a real run: "belongs to a
296+
# user, not an app" while the reaction author was github-actions[bot].
297+
#
298+
# Inverted to fail towards acting: a PAT identifies itself as type User,
299+
# and anything else — 403, empty, Bot — reacts.
300+
ME_TYPE=$(gh api user --jq .type 2>/dev/null) || ME_TYPE=""
301+
if [ "$ME_TYPE" = "User" ]; then
291302
echo "github-token belongs to a user, not an app; not reacting"
292303
exit 0
293304
fi
@@ -921,11 +932,25 @@ runs:
921932
POLICY_BLOCK_SNAPSHOT="${POLICY_BLOCK}.pre-l2"
922933
rm -f "$POLICY_BLOCK_SNAPSHOT"
923934
if [ -f "$POLICY_BLOCK" ]; then cp -f "$POLICY_BLOCK" "$POLICY_BLOCK_SNAPSHOT"; fi
924-
if node "$JUDGE" "$1" --model "$JUDGE_MODEL" --threshold "$JUDGE_THRESHOLD" --out "$L2_OUT" 2>&1 \
935+
# THE ROUTER DECIDES, unless judge-model overrides it. Its default is now
936+
# empty, which sends this router's ALIAS instead of a model name; judge.mjs
937+
# stamps x-cr-lens: judge, so the recipe's judge rule picks the model.
938+
#
939+
# Same model as before — the shipped recipe routes that rule to
940+
# deepseek-v4-pro, which is what judge-model used to name. What moves is
941+
# where the choice lives, so a workspace can price its judge by editing its
942+
# own recipe rather than waiting for a release.
943+
#
944+
# A recipe with NO judge rule sends the judge to its default, i.e. the
945+
# reviewer's own model, which agrees with itself while still reporting
946+
# success. That is why the provisioned recipe carries the rule, and why
947+
# setting judge-model explicitly stays the way to pin a model outright.
948+
L2_MODEL="${JUDGE_MODEL:-$ROUTER}"
949+
if node "$JUDGE" "$1" --model "$L2_MODEL" --threshold "$JUDGE_THRESHOLD" --out "$L2_OUT" 2>&1 \
925950
&& [ -s "$L2_OUT" ]; then
926951
L2_IN_COUNT=$(node -pe "(require('$1').comments||[]).length")
927952
L2_OUT_COUNT=$(node -pe "(require('$L2_OUT').comments||[]).length")
928-
echo "L2 judge ($JUDGE_MODEL, thr=$JUDGE_THRESHOLD): $L2_IN_COUNT -> $L2_OUT_COUNT"
953+
echo "L2 judge ($L2_MODEL, thr=$JUDGE_THRESHOLD): $L2_IN_COUNT -> $L2_OUT_COUNT"
929954
mv "$L2_OUT" "$1"
930955
# L2 succeeded. Restore engine's block if we snapshotted one —
931956
# covers the case where L2 was warn-mode guardrail-hit and
@@ -1580,8 +1605,19 @@ runs:
15801605
# of answering it more precisely each time.
15811606
#
15821607
# The review body and the summary say everything the reactions would.
1583-
ME_TYPE=$(gh api user --jq .type 2>/dev/null || echo "Bot")
1584-
if [ "$ME_TYPE" != "Bot" ]; then
1608+
# ONLY AN EXPLICIT "User" DECLINES. `gh api user` needs a user-scoped
1609+
# token, and github-token defaults to ${{ github.token }} — an
1610+
# installation token, for which that endpoint returns no user. The old
1611+
# `|| echo "Bot"` fallback was meant to cover that and did not: the call
1612+
# can exit 0 with no `.type`, so ME_TYPE came back empty, `!= "Bot"` held,
1613+
# and the reaction was skipped for EVERY consumer on the default token —
1614+
# the case this exists to serve. Measured on a real run: "belongs to a
1615+
# user, not an app" while the reaction author was github-actions[bot].
1616+
#
1617+
# Inverted to fail towards acting: a PAT identifies itself as type User,
1618+
# and anything else — 403, empty, Bot — reacts.
1619+
ME_TYPE=$(gh api user --jq .type 2>/dev/null) || ME_TYPE=""
1620+
if [ "$ME_TYPE" = "User" ]; then
15851621
echo "github-token belongs to a user, not an app; leaving reactions alone"
15861622
exit 0
15871623
fi

scripts/judge.mjs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
// correct, high-value defect in THIS change, (3) recommends keep/drop. We
77
// then keep one representative per surviving cluster above --threshold.
88
//
9-
// node judge.mjs <filtered.json> [--out f] [--threshold 0.7] [--model deepseek/deepseek-v4-pro]
9+
// node judge.mjs <filtered.json> [--out f] [--threshold 0.7] [--model orcarouter/<router>]
10+
//
11+
// --model takes either a router ALIAS (the normal case — the workspace's recipe
12+
// then picks the model, keyed on the `x-cr-lens: judge` header this sends) or a
13+
// concrete model name, which pins it regardless of the recipe.
1014
//
1115
// LLM connection resolution:
1216
// 1. OCR_LLM_URL / OCR_LLM_TOKEN / OCR_LLM_AUTH_HEADER env vars (production
@@ -117,7 +121,23 @@ const body = JSON.stringify({
117121

118122
const res = await fetch(llmUrl, {
119123
method: "POST",
120-
headers: { "content-type": "application/json", [llmAuthHeader]: "Bearer " + llmToken },
124+
headers: {
125+
"content-type": "application/json",
126+
[llmAuthHeader]: "Bearer " + llmToken,
127+
// THE ANGLE, so a router recipe can put this call on its own model.
128+
//
129+
// --model is normally the router ALIAS (action.yml passes it when judge-model
130+
// is unset), and an alias resolves through the workspace's DSL, which has no
131+
// other way to tell a judge call from a review call: the reviewer stamps no
132+
// angle. Without this header the judge takes the recipe's default — the
133+
// reviewer's own model — and a judge scoring work its own model produced
134+
// agrees with it, so the pass goes inert while still reporting success.
135+
//
136+
// Harmless when --model names a concrete model: nothing resolves an alias, so
137+
// nothing reads the header. Sent unconditionally rather than only for aliases
138+
// because "is this an alias" is the gateway's judgement, not this script's.
139+
"x-cr-lens": "judge",
140+
},
121141
body,
122142
});
123143
const raw = await res.text();

scripts/judge.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,12 @@ describe("request shape", () => {
454454
assert.equal(r.read().model, "engine-model");
455455
});
456456
});
457+
458+
// The judge's model normally comes from the workspace recipe, and the recipe has no
459+
// other way to tell this call from a review call — the reviewer stamps no angle. A
460+
// missing header sends the judge to the recipe's default, i.e. the model it is
461+
// scoring, which agrees with itself while still reporting success.
462+
test("the request stamps x-cr-lens: judge so a recipe can route it", () => {
463+
const src = readFileSync(new URL("./judge.mjs", import.meta.url), "utf8");
464+
assert.match(src, /"x-cr-lens":\s*"judge"/, "the judge call must declare its angle");
465+
});

0 commit comments

Comments
 (0)