Author SHA1 Message Date
steve b850e35b35 Merge pull request 'feat(qwen): let Qwen (and Kimi) join the swarm' (#30)
Build & push image / build-and-push (push) Successful in 39s
Build & push image / test (push) Successful in 9m42s
2026-08-12 22:46:04 +00:00
steveandClaude Opus 5 d8b023efd6 fix(qwen): the own-key fallback made the override path checkable
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m35s
Round 9's best finding is that my own round-8 change falsified a rationale I
wrote in round 5. The pre-flight skips the endpoint-override path because "a
built-in's own variable is never consulted there" — then I gave kimi/qwen an
own-key fallback that consults exactly that variable on exactly that path. So a
keyless override config sailed past the check and failed as a 401, which is the
failure the check exists to replace.

Now that the rule is statable for those two providers, they are checked on both
paths ("own key or GADFLY_API_KEY"), while everything else stays silent on the
override path because its rules still are not.

The missing-key hint on the GADFLY_ENDPOINT_* path named the endpoint variable
— telling a keyless operator to put a credential in a Gitea var, which is not
masked, and contradicting the README warning added one round earlier. It now
always names the provider's own masked secret.

Also: the model argument is trimmed, since Go trims GADFLY_MODEL and padding
would otherwise slip past the claude-code exemption; the test job takes
`permissions: contents: read`, being the one job that executes PR-authored
code; and the ollama-cloud rationale is stated once.

Deliberately not taken, with reasons rather than silence: the credential-scrub
bash could be extracted to a testable script like preflight.sh was — fair, and
a follow-up, since moving it now would be a fresh untested surface at merge
time. `tr -d [:space:]` strips POSIX whitespace where Go strips Unicode, which
differs only for a GADFLY_BASE_URL made entirely of non-ASCII spaces. And the
two provider tests overlap but assert different contracts that should be able
to fail independently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 18:36:12 -04:00
steveandClaude Opus 5 e67f95d777 fix(qwen): un-exempt opencode, and keep the Qwen key in a secret
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m40s
Two findings this round contradicted each other — one asked me to extend the
engine-spec exemption to a bare "opencode", the other said opencode should not
be exempt at all. The code settles it: that engine drives an ollama-cloud model
through the bundled CLI and authenticates with OLLAMA_API_KEY, so it needs
exactly the key the pre-flight checks. Exempting it, which I did last round,
switched the check off for the one engine it could still help. Only claude-code
is exempt now — it carries CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key —
and opencode/open-code get table rows so both spellings are covered.

The README told operators to embed the Qwen key in a GADFLY_ENDPOINT_* var,
while the workflow that forwards those vars warns in its own comments that vars
are NOT masked. Rather than only rewording the docs, a keyless kimi/qwen
endpoint now falls back to its own QWEN_API_KEY / KIMI_API_KEY — the same
vendor's key, so the no-cross-vendor rule is untouched — which lets the URL live
in a var and the credential in a secret. Break-checked by pointing that fallback
at OPENAI_API_KEY: the leak test catches it.

Smaller: isBuiltinCompatProvider mirrors isOpenAICompatProvider instead of an
inline slices.Contains, with a test that every builtin is also in the compat
list (a builtin missing from it would never reach the branch that protects it);
the preflight.sh rationale is stated once rather than in two comment blocks;
the Go test locates the shell script relative to its own source file; and the
gofmt step takes GOPROXY=off like its neighbours.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 18:23:33 -04:00
steveandClaude Opus 5 3af0f09387 fix(qwen): one credential rule for both paths — seven findings said so
Build & push image / build-and-push (pull_request) Successful in 5s
Build & push image / test (pull_request) Successful in 9m40s
Fourteen findings, and seven of them from all four models are the same one:
endpointProvider was missing the no-cross-vendor-fallback guard I had just
added to resolveModel. I fixed a credential leak on one path and left its
sibling leaking, in the commit whose own message argued those two paths must
move together. That is the third time in this PR.

So it is no longer a rule written twice. openAICompatOptions owns it and both
paths call it; builtinCompatProviders names the vendors that must never inherit
OPENAI_API_KEY, replacing a `provider == "kimi" || provider == "qwen"` literal
that was a fourth uncounted copy of the list.

The test drives a real request at a local server and demands two things: that
no request arrives carrying the OpenAI key, AND that the call fails closed
naming the variable to set — the second half because my first draft pointed the
provider at vendor.example, so the server saw nothing and the assertion held
for a reason unrelated to the fix. Break-checked: removing the guard puts
"Bearer sk-openai-must-not-travel" on the wire to the other vendor.

The scrub check failed open. As a bare condition, a grep ERROR (exit >= 2)
reads as "not found" and skips the guard — a credential check that passes
precisely when it cannot see the filesystem it is searching. It now
distinguishes 0/1/>=2 and refuses to continue on error.

A bare "claude-code" spec has no "/", so the provider fell back to ollama-cloud
and the pre-flight would skip a reviewer that authenticates with
CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key. Engine specs are now exempt.

preflight.sh's provider list duplicated its own case arms; both now read one
table. And its comment claimed the Go cross-check fails if either list misses
an entry from the other, when only one direction is checked — the reverse is
not even desirable, since ollama-cloud and anthropic belong in that table and
not in the Go one. The comment now says what is enforced.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 18:09:18 -04:00
steveandClaude Opus 5 274451e89c fix(ci): actually add grep -e (a3d3a45 claimed it and did not)
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m38s
The previous commit message listed this fix; the edit never made it into the
tree. Checked because I have done exactly that before, and the message is not
the change.

Verified rather than assumed, and the behaviour is worth recording: with a
password beginning with "-", `grep -rqF "$PW"` returns 0 against a directory
containing no such string, so the scrub check would have reported a leak that
did not exist and failed the step with a misleading message. With -e it
correctly returns 1 when clean and 0 when the credential really is present.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:52:57 -04:00
steveandClaude Opus 5 a3d3a45e7e fix(qwen): bump majordomo, and stop handing keys to the wrong vendor
Build & push image / build-and-push (pull_request) Successful in 9s
Build & push image / test (pull_request) Canceled after 59s
Round 6, and one finding exposed something no reviewer mentioned: the majordomo
bump this whole PR depends on was never made. Every test here builds the openai
client directly, so all of them passed against a majordomo release that had
never heard of qwen — a plain "qwen/<model>" in GADFLY_MODELS, the primary way
anyone will use this, would not have resolved at all. A compile error caught it,
which is luck. TestBuiltinCompatProvidersResolveViaRegistry now exercises that
path; the build is what guards the dep itself, since the old release cannot
compile the code below.

On the endpoint-override path, kimi and qwen fell through to openai.New's
OPENAI_API_KEY default whenever GADFLY_API_KEY was unset — sending an OpenAI
key to Moonshot or Alibaba. That is a credential handed to the wrong vendor,
and it is the exact failure majordomo's built-ins are written to prevent; I
reintroduced it one layer up. Both now pass the key unconditionally, so an
absent key is a 401 naming GADFLY_API_KEY rather than a foreign credential on
the wire.

The test job scrubbed the registry credential and left the checkout token in
.git/config, readable by the `go test` it then runs — fixing one credential
while its neighbour sat in the open. persist-credentials: false; nothing in
that job talks to git after checkout.

The cross-language wiring test now QUERIES preflight.sh via a new
gadfly_preflight_providers function instead of regexing its case statement.
Parsing made that file's formatting a contract no linter enforces, where a
harmless reformat breaks a test in another language. Two models flagged it.

Also: grep for the scrub check takes -e, so a password starting with a hyphen
is not read as options; and key_hint stopped repeating key_env in four of five
arms.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:51:55 -04:00
steveandClaude Opus 5 0abcd16e9e fix(ci): make the credential scrub failure-safe, and stop the lists drifting
Build & push image / build-and-push (pull_request) Successful in 1m13s
Build & push image / test (pull_request) Successful in 9m41s
Round 5, and the best findings are again about the fix from round 4.

The scrub only ran on success. `set -e` aborts the step when `go mod download`
fails, so the cleanup line after it never executed — leaving a push-capable
credential on a long-lived self-hosted runner for whatever job landed there
next. It is now a `trap ... EXIT`, verified against a simulated failure.

It also scrubbed the wrong file in principle: `git config --global` writes to
GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that exists, else
~/.gitconfig — so deleting ~/.gitconfig can scrub a path the credential was
never in. The step now names GIT_CONFIG_GLOBAL itself, leaving exactly one file
to remove.

And the verification failed open in the case that matters most: `grep -F ""`
matches every file, so a run WITHOUT the secret — a fork PR, the threat model —
failed the check with a message accusing it of leaking a credential it never
had. Guarded on a non-empty secret.

Credentials move to an Authorization header instead of being embedded in the
URL, so a password containing @ : / or # can no longer break URL parsing in a
way that reads as a bad password.

Two list-drift holes closed with one test that reads across languages:
TestOpenAICompatProvidersAreFullyWired asserts every openAICompatProviders
entry is both advertised in endpointProviderNames and has a credential arm in
scripts/preflight.sh. Adding a compat provider touches three places in two
languages and nothing connected them. Break-checked in both directions.

Finally, a whitespace-only GADFLY_BASE_URL disagreed across the boundary: Go
TrimSpaces it and takes the registry path, bash called it "set" and skipped the
pre-flight, so the missing key arrived as a bare 401 with no notice. Both now
agree on what unset means.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:40:03 -04:00
steveandClaude Opus 5 14f8533e38 fix(ci): scrub the registry credential before running repo code
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m47s
Both Claude reviewers caught this independently, and they are right. The test
job I added wrote a PUSH-CAPABLE REGISTRY_PASSWORD into a plaintext
~/.gitconfig and then ran `go build`/`go vet`/`go test` — repository code — on
pull_request events. This repo is public, so a fork PR could ship a test whose
only job is to print that file. The image build had already answered this
question correctly: its credentials are BuildKit secrets scoped to the
module-download RUN and are never present while code executes. I bolted on a
job that skipped the boundary its neighbour maintains.

Dependencies are now fetched in their own step which deletes ~/.gitconfig
before anything else runs, and asserts the scrub — against the whole home
directory, not against the file it just removed, because the credential can
also land in ~/.netrc or ~/.config/go/env. Verified the assertion is not
vacuous: planting the secret in ~/.netrc trips it. Later steps run with
GOPROXY=off, so any attempt to reach the network fails loudly rather than
quietly hunting for the credential that is now gone.

Also from round 4: TestEndpointProviderNamesAreAllAccepted pinned only
endpointProvider, while the constant is the error text for BOTH resolution
paths — it now asserts each advertised name resolves either way (break-checked
by dropping the gemini alias from resolveModel alone). preflight.sh documents
that ollama-cloud is checked on OLLAMA_API_KEY but hinted as
OLLAMA_CLOUD_API_KEY because run.sh copies one to the other first, an ordering
dependency that was invisible from the file.

And the comments that narrated this PR's own edit history ("the first version
of this change...") are rewritten as invariants. That history stops being true
the moment this merges, and the repo's doc policy says as much.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:28:08 -04:00
steveandClaude Opus 5 ebfaeba07e docs(qwen): warn that Model Studio keys are endpoint-scoped
A workspace-scoped Qwen endpoint rejects a key issued for the shared
international host with a genuine 'Incorrect API key provided', so a valid key
reads as invalid and the obvious next move — checking the key — confirms it is
fine and leads nowhere. Watched this cost real debugging time on a live
deployment today; gadfly would hit it identically. Documents the
GADFLY_ENDPOINT_* form, which reaches a workspace host with no code change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:22:08 -04:00
steveandClaude Opus 5 67a73616e1 fix(qwen): gadfly round 3 — stop guarding a duplicate, delete it
Build & push image / build-and-push (pull_request) Successful in 5s
Build & push image / test (pull_request) Canceled after 7m27s
Twelve findings, all real, and the two that matter are about the pre-flight I
added rather than about qwen.

The credential check had a false pass in the OTHER direction from round 2's: on
the GADFLY_BASE_URL override path, resolveModel builds the client with
GADFLY_API_KEY and never reads QWEN_API_KEY/KIMI_API_KEY, so treating the
provider's own key as sufficient there let a doomed run proceed. Having now
been wrong about these rules in both directions, the check no longer tries to
model both paths: it covers the REGISTRY path, whose rules it can state
exactly, and says nothing about the override path — which is hand-configured by
definition, while the registry path is the one you hit by adding a model id to
a var and forgetting the secret.

The logic moves to scripts/preflight.sh, sourced by both run.sh and the test.
The previous answer to "this test duplicates production logic" was a regex
drift-guard, and that guard compared only the provider table — not the decision
logic, which is precisely the half that carried the bug. A duplicate you guard
is still a duplicate; this deletes it, and the test now runs under `set -u`
like production does.

Also: the test that pins the shared provider slice held its own copy of the
list (now ranges the slice); endpointProviderNames had nothing tying it to the
switches it describes, which is how it shipped without "gemini" (a new test
asserts every advertised name resolves); two godoc lists had drifted; and the
"sanity" line that asserted nothing is gone.

And the repo had NO test job — `go test` and the pre-flight table both existed
and neither was ever executed by CI, which reads as coverage while providing
none. Added one (build/vet/gofmt/test/pre-flight), running alongside the image
build rather than gating it, so red is loud without standing between a push and
a rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:20:44 -04:00
steveandClaude Opus 5 1d6eaa08c5 fix(qwen): gadfly round 2 — the anti-drift list had already drifted
Build & push image / build-and-push (pull_request) Successful in 3s
Eight findings, all real, and the sharpest ones are about this PR's own fixes.

GADFLY_API_KEY was treated as a universal substitute in the pre-flight. It is
not: resolveModel reads it only AFTER the `baseURL == ""` early return, so on
the registry path — the documented primary path — a qwen/kimi built-in reads
its own variable and GADFLY_API_KEY is never consulted. A mis-set
GADFLY_API_KEY therefore passed pre-flight and 401'd five times anyway, which
is precisely the failure this check exists to prevent. It now only substitutes
when GADFLY_BASE_URL is also set.

`openai-compatible` was missing from the pre-flight table while both switches
accept it as an OPENAI_API_KEY alias, so that one spelling still fell through
to the cryptic five-failure mode.

endpointProviderNames — the constant I introduced *to stop* the two error
messages drifting — omitted the `gemini` alias both switches accept. It now
lists every accepted spelling.

And the case list itself was still duplicated across both switches plus the
test that pins them: three copies of the thing whose duplication started this.
Both switches now call isOpenAICompatProvider over one shared slice, and
endpointProvider's doc comment points at endpointProviderNames instead of
carrying a fourth hand-written copy.

scripts/preflight_test.sh moves into the repo (20 cases, up from 17, covering
openai-compatible and both GADFLY_API_KEY directions). It carries a drift guard
that diffs its copy of the provider table against run.sh's and aborts if they
differ — break-checked by deleting an arm from run.sh, which fails it loudly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:59:01 -04:00
steveandClaude Opus 5 2367e696b5 fix(qwen): gadfly round 1 — three real findings, all sibling drift
Build & push image / build-and-push (pull_request) Successful in 3s
The pre-flight comment was the worst of them, and three models agreed. It said
providers absent from the table "need no key or carry it in their endpoint/DSN"
— false for google, which needs a key and is absent for an entirely different
reason: it accepts GOOGLE_API_KEY *or* GEMINI_API_KEY, so a single-variable arm
would silently skip a correctly-configured reviewer. That reasoning was in the
PR description and not in the code, so the comment invited exactly the wrong
edit. It now states both exclusion reasons and names google's.

Forwarded KIMI_API_KEY alongside QWEN_API_KEY in the dogfooding stub. This PR
argues that sibling call sites must move together, and I declared both secrets
in the reusable workflow and forwarded one — a config that looks complete and
401s on the model you didn't wire.

The two endpoint-provider error messages listed the same accepted set in
different order and spelling. Both functions accept an identical set, so they
now share one endpointProviderNames constant and cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:43:00 -04:00
steveandClaude Opus 5 0f40b21d79 feat(qwen): let Qwen (and Kimi) join the swarm
Build & push image / build-and-push (pull_request) Successful in 14s
Gadfly review (reusable) / review (pull_request) Successful in 8m46s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m46s
majordomo now ships qwen and kimi as built-ins that ARE the openai client at
their own base URL, so "qwen/qwen3.8-max" works as a GADFLY_MODELS entry once
the key reaches the container. This wires up the parts that key has to pass
through.

Two provider switches had to learn the names, not one. resolveModel's
GADFLY_BASE_URL override was the obvious one; endpointProvider's
GADFLY_ENDPOINT_* parser is its sibling, and I fixed the first and missed the
second on the first pass — a config that resolves one way and errors the other
for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths
now asserts both from one table so the pair fails together; break-checked in
both directions.

QWEN_API_KEY (and KIMI_API_KEY) are declared as workflow_call secrets and
forwarded to the container, with gadfly's own stub forwarding QWEN_API_KEY so a
qwen entry can join the default swarm by editing GADFLY_DEFAULT_MODELS alone —
no workflow edit, no re-release.

The run.sh credential pre-flight is now a provider→variable table instead of an
ollama-cloud special case. Without it a forgotten key surfaces as five
identical per-lens agent failures naming no variable, and the operator reads a
stack trace to find out which secret they missed. Google stays out of the table
on purpose: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, and a one-var
entry would wrongly skip a correctly-configured run. Verified across 17
provider x key-state combinations, including that a wrong-provider key never
satisfies qwen (majordomo refuses cross-provider fallback) and that unkeyed
providers are never blocked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:30:29 -04:00
steve d272695c16 Merge pull request 'feat(reusable): resolve the reviewer image tag at runtime (GADFLY_REVIEWER_TAG)' (#28) from reviewer-tag-runtime-var into main
Build & push image / build-and-push (push) Successful in 6s
Reviewed-on: #28
2026-07-27 03:32:45 +00:00
steveandClaude Fable 5 4dafac0d13 feat(reusable): resolve the reviewer image tag at runtime (GADFLY_REVIEWER_TAG)
Build & push image / build-and-push (pull_request) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 6m42s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m43s
Nearly every consumer re-pin of review-reusable.yml was a reviewer IMAGE bump,
not a structural change — but the tag was baked into a `uses: docker://` step,
which is parsed before any expression context exists and so can't read a
variable. Run the reviewer as the JOB container instead (container.image DOES
accept expressions) with an explicit `run: /entrypoint.sh` step: the tag now
resolves per-run via inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the
baked fallback pin (sha-b37cd09, unchanged).

A Gadfly release becomes: build the image, update the ONE user-level variable —
every consumer pinned to this file's @<sha> picks it up on its next review with
no re-pin. Workflow re-pins remain only for structural yml changes (this is one
— intended to be the last routine one).

- Entrypoint contract unchanged: same env block, same /entrypoint.sh brains
  (bash + entrypoint already live in the image; ENTRYPOINT was /entrypoint.sh).
- Keep the variable on immutable sha- tags, never :latest (act_runner caches
  :latest and often does not re-pull a moved one).
- Tradeoff noted in comments: vars are unmasked and move without a commit
  trail — same blast radius as editing this file, minus the audit trail.
- README (image/pinning para + Central config table) and examples/reusable.yml
  updated per the maintenance rule.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 22:43:37 -04:00
steveandClaude Opus 4.8 c9dab69d14 chore(reusable): pin the reviewer image to sha-b37cd09 [skip ci]
Adopt the provider-wide lens budget build (PR #27) for gadfly's own reviews
and for consumers pinning this reusable. No image rebuild needed — the tag
already exists — so skip CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:50:20 -04:00
steve b37cd09dc9 Merge pull request 'feat(concurrency): provider-wide lens budget, drop the model cap' (#27) from feat/provider-wide-lens-budget into main
Build & push image / build-and-push (push) Successful in 6s
2026-07-18 16:48:26 +00:00
steveandClaude Opus 4.8 6a74b64c7a fix(concurrency): address gadfly review — fail-open pool + doc drift
Build & push image / build-and-push (pull_request) Successful in 4s
Gadfly's own review of #27 surfaced a real robustness cluster (5 models,
error-handling) plus stale comments I missed.

Robustness — the flock permit pool could hang forever:
- tryAcquire swallowed os.OpenFile errors and treated every flock error as
  "busy", so a broken/missing pool dir (or a filesystem without flock) would
  spin-poll indefinitely; the fanout context is uncancellable and the per-lens
  timeout only starts AFTER acquire returns. Can't trigger in the deploy
  (entrypoint mkdir -p's the dir) but fixed defensively.
- tryAcquire now returns a structural error, distinguished from a healthy-full
  pool (EWOULDBLOCK = busy → keep polling). acquire FAILS OPEN on a structural
  error: logs once and runs the lens unthrottled rather than hanging the review.
- acquire uses time.NewTimer + Stop() (no per-poll timer leak on cancellation).
- activeLensSem warns on stderr when GADFLY_LENS_SEM_DIR is set but the size is
  invalid (was a silent degrade to unthrottled).
- New test: a broken pool dir fails open promptly.

Doc drift (stale references to the removed model cap):
- main.go defaultLensConcurrency + runSpecialists doc, entrypoint.sh status
  pre-seed + lane-launch comments, and the pre-existing lens_concurrency_test.go
  header all updated to the provider-wide-budget wording.

Accepted (graded real, not changed): all-models-start-at-once startup burst
(intended tradeoff) and index-0 sweep bias (cosmetic). One false positive
(one model using the whole budget is the intended lone-model behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:47:36 -04:00
steveandClaude Opus 4.8 74831368ab feat(concurrency): provider-wide lens budget, drop the model cap
Build & push image / build-and-push (pull_request) Successful in 4s
Gadfly review (reusable) / review (pull_request) Successful in 14m47s
Adversarial Review (Gadfly) / review (pull_request) Successful in 14m47s
Concurrency was two multiplicative gates in two processes: entrypoint.sh
capped MODELS-at-once per provider (GADFLY_PROVIDER_CONCURRENCY) while each
model's binary separately capped its own lenses (GADFLY_LENS_CONCURRENCY).
A model therefore held its whole model-slot until its LAST lens finished,
stalling the next model even with idle lens capacity.

Collapse to one throttle: a provider-wide lens budget shared across all of
that provider's models. entrypoint now runs every model in a lane at once and
seeds a single cross-process permit pool per lane (a dir of N flock files,
sized by GADFLY_PROVIDER_LENS_CONCURRENCY -> GADFLY_LENS_CONCURRENCY). Each
lens pass (review+recheck) acquires a permit before it runs and releases it
after, so a model winding down immediately yields its freed permits to
another model's queued lenses. flock auto-releases on process death, so a
killed/crashed model can't leak budget.

- cmd/gadfly/lenssem.go: the flock permit pool (+ lenssem_test.go).
- main.go: runSpecialists holds a shared permit per lens; fanout sized to the
  budget so a lone model can use all of it. Falls back to the in-process limit
  when no pool is set (local runs, tests).
- entrypoint.sh: drop provider_cap/DEFAULT_CONC; run_lane runs all models and
  seeds the per-lane pool.
- GADFLY_PROVIDER_CONCURRENCY / GADFLY_CONCURRENCY are now ignored; the
  reusable workflow marks provider_concurrency deprecated and stops forwarding
  it. Docs (README, CLAUDE.md, examples) updated per the maintenance rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:22:11 -04:00
steveandClaude Opus 4.8 0d51879450 chore(reusable): bump reviewer image to sha-bb98fae (opencode-capable)
Build & push image / build-and-push (push) Successful in 5s
sha-bb98fae is the image built from the PR #26 merge, so it bundles the opencode
CLI and can run the new opencode/<model> engine. Consumers pinned to the gadfly
ref of this commit inherit it. Previous pin (sha-f468fe6) predated opencode.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:56:17 -04:00
steve bb98fae5f0 Merge pull request 'feat(engine): add opencode CLI review engine' (#26) from feat/opencode-engine into main
Build & push image / build-and-push (push) Successful in 41s
2026-07-18 05:51:51 +00:00
steveandClaude Opus 4.8 2477e50230 fix(opencode): address gadfly's dogfood review
Build & push image / build-and-push (pull_request) Successful in 8s
Gadfly's own swarm reviewed PR #26 and reached consensus (3/3 models) on a real
bug, plus flagged security/maintainability items. Fixes:

- Pass-through auth (BLOCKING, 3/3 agreement): openCodeEnv() stripped every
  provider key except OLLAMA_API_KEY, so the documented opencode/<provider>/<model>
  escape hatch (e.g. opencode/anthropic/...) had no way to authenticate — the
  reusable workflow forwards ANTHROPIC_API_KEY/OPENAI_API_KEY into the container
  and the allowlist discarded them. Now forward ANTHROPIC_*/OPENAI_*/GOOGLE_*/
  GEMINI_* so OpenCode's built-in providers can authenticate, while still
  withholding gadfly's own secrets (Gitea/findings tokens, claude-code OAuth).

- Read-only hardening (security lens): the generated config denied only edit/bash.
  Using OpenCode's documented permission schema, also deny webfetch/websearch/
  external_directory — the network + out-of-sandbox tools — closing the
  exfiltration surface a prompt-injected review could otherwise reach. Permission
  is now a map so the deny set is extensible.

- Dedup (maintainability lens, 3/3): extract shared filterEnv() and
  killGroupOnCancel() helpers in engine.go, used by both shell-out engines'
  runPass/env builders instead of the copy-pasted blocks.

- Cosmetic: split the const block so defaultOpenCodeBaseURL's doc comment no
  longer visually misattaches to the agent-name const.

README updated: the read-only note and the reduced-env note now reflect the
broader deny set and the forwarded provider keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:29:29 -04:00
steveandClaude Opus 4.8 463aa01ddb fix(dogfood): bump reusable image pin past the Gitea 1.27 reclassification
Build & push image / build-and-push (pull_request) Successful in 4s
Gadfly review (reusable) / review (pull_request) Successful in 5m25s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m25s
The self-review kept skipping every PR with "event 'workflow_call' not handled":
review-reusable.yml pinned the reviewer image at sha-3095ebf, which predates the
entrypoint.sh reclassification (added in 9d74cb9) that maps a called workflow's
github.event_name = 'workflow_call' back to pull_request/issue_comment. Under
Gitea >= 1.27 the container therefore saw an unhandled event and self-skipped in
~1s, even though PR was populated.

Bump the pin to sha-f468fe6 (current main HEAD, which contains the fix) so the
dogfood path actually reviews. The pr_number threading in the prior commit fixes
the manual-dispatch input propagation; this fixes the pull_request path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:14:27 -04:00
steveandClaude Opus 4.8 3973e469a8 fix(dogfood): thread dispatch pr_number into gadfly's own review caller
Gadfly review (reusable) / review (pull_request) Successful in 1s
Adversarial Review (Gadfly) / review (pull_request) Successful in 1s
Build & push image / build-and-push (pull_request) Successful in 2m13s
gadfly's self-review adversarial-review.yml calls review-reusable.yml but was
never updated for the Gitea >= 1.27 breaking change (go-gitea#37478): a called
workflow no longer receives the caller's workflow_dispatch inputs in
github.event, so a manual "review PR #N" dispatch reached the reusable with an
empty PR. Commit 64d34bd added the pr_number workflow_call input and mort's
caller threads it; this brings gadfly's own caller in line so the dogfood path
works on the same Gitea version.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:07:56 -04:00
steveandClaude Opus 4.8 5ab4074e9c feat(engine): add opencode CLI review engine
Gadfly review (reusable) / review (pull_request) Successful in 5s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5s
Build & push image / build-and-push (pull_request) Successful in 2m43s
Add a third review harness alongside the in-process majordomo loop and the
claude-code CLI shell-out: the OpenCode CLI (opencode.ai) driving an ollama-cloud
model, selected by an "opencode/<model>" spec. The goal is to benchmark gadfly's
boutique executus harness against a freely-available agentic harness on the SAME
model (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").

OpenCode has no --append-system-prompt flag, so the lens system prompt and the
read-only discipline are delivered through a generated config injected via
OPENCODE_CONFIG_CONTENT: a "gadfly" agent whose prompt is the system prompt with
edit/bash denied at both the global and agent level, plus a "gadfly" ollama-cloud
provider. That env var is the highest-precedence config source in the container,
so a reviewed repo's own opencode.json can't re-enable edits on the reviewer.

Spec forms: "opencode/<model>" (wrapped in the generated provider), the
"open-code/" alias, "opencode/<provider>/<model>" pass-through to OpenCode's own
registry, and bare "opencode". Model ids are taken verbatim so colon-bearing
ollama ids (qwen3-coder:480b-cloud) survive. Auth reuses OLLAMA_CLOUD_API_KEY
(mapped to OLLAMA_API_KEY, referenced as {env:OLLAMA_API_KEY} in config, never a
literal secret). Knobs mirror GADFLY_CLAUDE_*: GADFLY_OPENCODE_BIN/MODEL/BASE_URL/
EXTRA_ARGS. openCodeEnv() forwards OLLAMA_API_KEY (the inverse of claudeEnv) but
still withholds the Gitea/findings/Anthropic secrets.

main.go engine selection is now a switch (claude-code / opencode / majordomo), and
the auto-select path uses a type-check instead of a boolean so a shell-out engine
can never hit the *majordomoEngine assertion. auto-select and delegate_investigation
stay majordomo-only and are skipped for opencode (the CLI does its own legwork).

Dockerfile bundles opencode-ai (npm auto-selects its musl build on alpine) with a
best-effort version check + provider pre-warm that never fails the shared image
build. README/examples/CLAUDE.md/scripts updated per the maintenance rules.

Tests: new opencode_test.go mirrors engine_test.go (spec/model/args/config/env-
filter + stub-CLI runtime tests). Verified end-to-end with a fake opencode CLI:
correct argv, injected config, and consolidated markdown output.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:00:54 -04:00
steve f468fe6245 Merge pull request 'fix(reusable): thread the dispatch pr_number as a workflow_call input' (#24) from fix/dispatch-pr-input into main
Build & push image / build-and-push (push) Successful in 3s
2026-07-16 03:34:29 +00:00
steveandClaude Fable 5 64d34bd33b fix(reusable): thread the dispatch pr_number as a workflow_call input
Gadfly review (reusable) / review (pull_request) Successful in 1s
Adversarial Review (Gadfly) / review (pull_request) Successful in 1s
Build & push image / build-and-push (pull_request) Successful in 3s
Gitea >= 1.27 does not propagate the caller's workflow_dispatch inputs
into a called workflow's github.event (same rework that changed
event_name), so a manual 'review PR #N' dispatch arrived with an empty
PR and died at the entrypoint's 'PR required' check. Accept pr_number as
an explicit workflow_call input and fold it into the PR env fallback
chain; caller stubs pass github.event.inputs.pr_number through.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:34:07 -04:00
steve d8580bc193 Merge pull request 'chore(reusable): replace the retired ragnaros endpoint with netherstorm' (#23) from chore/ragnaros-to-netherstorm into main
Build & push image / build-and-push (push) Successful in 18s
2026-07-16 03:32:18 +00:00
steveandClaude Fable 5 24d1ee1ebd chore(reusable): replace the retired ragnaros endpoint with netherstorm
Build & push image / build-and-push (pull_request) Successful in 3s
Gadfly review (reusable) / review (pull_request) Successful in 5s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5s
GADFLY_ENDPOINT_RAGNAROS is empty in Gitea vars and no ragnaros/<model>
is in the pool; netherstorm is the live local GPU endpoint. Requested by
Steve.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:31:57 -04:00
steve 56392368dd Merge pull request 'chore(reusable): forward GADFLY_ENDPOINT_NETHERSTORM' (#22) from chore/forward-netherstorm-endpoint into main
Build & push image / build-and-push (push) Successful in 8s
2026-07-16 03:30:43 +00:00
steveandClaude Fable 5 4ff63d988a chore(reusable): forward GADFLY_ENDPOINT_NETHERSTORM
Gadfly review (reusable) / review (pull_request) Successful in 0s
Adversarial Review (Gadfly) / review (pull_request) Successful in 0s
Build & push image / build-and-push (pull_request) Successful in 26s
A reusable workflow can't enumerate arbitrary vars.GADFLY_ENDPOINT_*;
the netherstorm endpoint var was added after the forwarding list, so
every netherstorm/<model> reviewer failed with 'unknown provider:
netherstorm' — the correctly-formatted var never reached the container.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:30:17 -04:00
steve 256344d3e1 Merge pull request 'fix: handle Gitea 1.27's workflow_call event name in the trigger gate' (#21) from fix/gitea-127-workflow-call into main
Build & push image / build-and-push (push) Successful in 2m30s
2026-07-16 03:00:40 +00:00
25 changed files with 2102 additions and 190 deletions
+13
View File
@@ -46,6 +46,16 @@ jobs:
secrets:
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Forwarded so a "qwen/<model>" or "kimi/<model>" entry can join the
# swarm by editing the GADFLY_DEFAULT_MODELS var alone — no workflow
# edit, no re-release. Both are forwarded together on purpose: the
# reusable workflow declares both, and forwarding only one is a config
# that looks complete and 401s on the model you didn't wire. Empty until
# the repo secret exists, which is a 401 on that one model, not a broken
# review. NB kimi/<model> is Moonshot's own API — a different route than
# the kimi-k2.6:cloud swarm entry, which rides OLLAMA_CLOUD_API_KEY.
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
with:
@@ -53,3 +63,6 @@ jobs:
# 5-lens suite) from review-reusable.yml. Only the consumer-specific
# allow-list is set here.
allowed_users: "steve,fizi,dazed"
# Gitea >= 1.27 does not propagate dispatch inputs into a called workflow's
# github.event — thread the PR number explicitly (empty on non-dispatch events).
pr_number: ${{ github.event.inputs.pr_number }}
+102
View File
@@ -45,6 +45,108 @@ env:
IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly
jobs:
# Runs alongside the image build rather than gating it: a red test should be
# loud on the PR without standing between Steve and a rebuild. Added because
# this repo had NO test job at all — `go test` and scripts/preflight_test.sh
# both existed and neither was ever executed by CI, which is worse than
# having no tests, since it reads as coverage.
test:
runs-on: ubuntu-latest
timeout-minutes: 15
# This job executes repository code (`go test`) on pull_request, so it gets
# the narrowest token the platform will give it. Nothing here writes.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
# Scrubbing the registry credential while leaving the checkout token
# in .git/config would just move the prize: `go test` below runs
# repository code with the workspace readable. Nothing in this job
# talks to git after checkout, so the token has no reason to persist.
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Fetch dependencies, then DESTROY the credential before any step that
# executes repository code. REGISTRY_PASSWORD is push-capable, this repo
# is public so pull_request runs can carry attacker-authored code, and
# `go test` runs that code — a plaintext ~/.gitconfig left in place is a
# credential any test could print. The image build faces the same
# question and answers it the same way: its creds are BuildKit secrets
# scoped to the module-download RUN, never present while code runs.
- name: Fetch private modules
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
# Own the config path outright. `git config --global` writes to
# GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that
# directory exists, else ~/.gitconfig — so "delete ~/.gitconfig"
# scrubs a file the credential may never have been in. Naming the
# path leaves exactly one file to remove.
export GIT_CONFIG_GLOBAL="$(mktemp)"
# Scrub on ANY exit, not just success. `set -e` means a failed
# `go mod download` aborts this step, and a cleanup written as the
# next line would never run — leaving a push-capable credential on a
# long-lived self-hosted runner for whatever job lands there next.
trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
# Basic-auth header rather than credentials inside the URL: a
# password containing @ : / or # breaks URL parsing, and the failure
# would look like a bad password rather than a quoting bug.
git config --global \
"http.https://gitea.stevedudenhoeffer.com/.extraheader" \
"Authorization: Basic $(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
go mod download
rm -f "$GIT_CONFIG_GLOBAL"
test ! -e "$GIT_CONFIG_GLOBAL"
# Prove the scrub across the whole home dir, not just the file we
# deleted — that check would pass no matter what, and git/go can also
# write ~/.netrc or ~/.config/go/env. Guarded on a non-empty secret:
# `grep -F ""` matches every file, so a secretless run (fork PR) would
# fail here with a message accusing it of leaking nothing.
# -e, so a password beginning with "-" is a pattern and not options.
# And distinguish grep's three exits: 0 found, 1 clean, >=2 ERROR. As
# a bare condition an error reads as "not found" and the guard is
# skipped — a check that fails OPEN in exactly the case where it can no
# longer see the filesystem it is supposed to be searching.
if [ -n "${REGISTRY_PASSWORD:-}" ]; then
set +e
grep -rqF -e "$REGISTRY_PASSWORD" "$HOME" 2>/dev/null
rc=$?
set -e
case "$rc" in
0) echo "::error::registry credential still present under \$HOME after scrub"; exit 1 ;;
1) : ;; # clean
*) echo "::error::credential scrub check could not run (grep exit $rc); refusing to continue"; exit 1 ;;
esac
fi
# GOPROXY=off from here on: the module cache is already warm, so any
# attempt to reach the network is a bug — and it fails loudly instead of
# quietly looking for the credential that is now gone.
- name: go build
env: { GOPROXY: "off" }
run: go build ./...
- name: go vet
env: { GOPROXY: "off" }
run: go vet ./...
- name: gofmt
env: { GOPROXY: "off" }
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: go test
env: { GOPROXY: "off" }
run: go test -count=1 ./...
- name: pre-flight credential table
run: bash scripts/preflight_test.sh
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 20
+68 -18
View File
@@ -24,11 +24,14 @@
# if you accept that exposure; the explicit form is recommended. GITEA_TOKEN is
# the automatic job token (no need to forward it).
#
# Advisory only — never blocks a merge. The image is pinned to an immutable
# :sha- tag here (act_runner caches :latest); bump it per Gadfly release.
# Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate
# releases, so central tuning here propagates without per-consumer edits — or a
# full `@<sha>` for an immutable pin. Avoid `@main` (moves on every push).
# Advisory only — never blocks a merge. The reviewer image tag ALSO resolves at
# runtime (inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the fallback pin
# baked into the `container:` line below), so a Gadfly release is: build the
# image, update ONE variable — no consumer re-pin. Re-pin the workflow ref only
# for structural changes to this file.
# Consumers should pin `uses: ...@<sha>` — long-lived act_runners cache this file
# by ref, so a moved tag (@v1) or @main is often NOT re-fetched and silently runs
# a stale copy.
name: Gadfly review (reusable)
@@ -43,9 +46,10 @@ on:
# NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache).
#
# Owner-set user-scope variables (see README "Central config via variables"):
# GADFLY_REVIEWER_TAG (the reviewer image tag this reusable runs),
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
# GADFLY_DEFAULT_PROVIDER_CONCURRENCY, GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY,
# GADFLY_ENDPOINT_RAGNAROS (the 4090 Ti endpoint).
# GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY (the provider-wide lens budget),
# GADFLY_ENDPOINT_NETHERSTORM (the local GPU box endpoint).
# An unset variable + no input → the image default (one model, default suite),
# so a public consumer with neither still gets a sane minimal review.
inputs:
@@ -53,12 +57,18 @@ on:
specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS
provider: { type: string, default: "" } # GADFLY_PROVIDER
base_url: { type: string, default: "" } # GADFLY_BASE_URL
provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_CONCURRENCY
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
provider_concurrency: { type: string, default: "" } # DEPRECATED / ignored — the per-provider MODEL cap was removed; the lens budget below is the single throttle. Kept so existing callers don't error.
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — the per-provider lens budget (shared across the provider's models); empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens)
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS (consumer-specific; set in your stub)
reviewer_tag: { type: string, default: "" } # reviewer image tag (e.g. "sha-b37cd09") — empty falls back to user var GADFLY_REVIEWER_TAG, then the pin baked into the container: line
# Gitea >= 1.27 does not propagate the CALLER's workflow_dispatch inputs into a
# called workflow's github.event, so a manual "review PR #N" dispatch arrived
# here with an empty PR and died at the entrypoint's "PR required" check. The
# caller stub must thread it explicitly: `pr_number: ${{ github.event.inputs.pr_number }}`.
pr_number: { type: string, default: "" }
trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE
consolidate: { type: string, default: "" } # GADFLY_CONSOLIDATE — "" => auto (one consensus comment for >=2 models); "0" => one comment per model
inline_review: { type: string, default: "" } # GADFLY_INLINE_REVIEW — "" => on (post a COMMENT-state PR review with inline comments on changed lines); "0" => off
@@ -77,6 +87,15 @@ on:
OPENAI_API_KEY: { required: false }
ANTHROPIC_API_KEY: { required: false }
GOOGLE_API_KEY: { required: false }
# Alibaba Model Studio (Qwen), for GADFLY_MODELS entries like
# "qwen/qwen3.8-max". NOT interchangeable with OPENAI_API_KEY: majordomo's
# qwen built-in reads QWEN_API_KEY only and deliberately refuses to fall
# back to the OpenAI key, so an unforwarded secret is a 401, not a
# mis-billed OpenAI call.
QWEN_API_KEY: { required: false }
# Moonshot (Kimi) over its own API — distinct from the ollama-cloud
# "kimi-k2.6:cloud" entry, which is keyed by OLLAMA_CLOUD_API_KEY.
KIMI_API_KEY: { required: false }
GADFLY_API_KEY: { required: false }
CLAUDE_CODE_OAUTH_TOKEN: { required: false }
GADFLY_FINDINGS_URL: { required: false }
@@ -93,8 +112,27 @@ jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
# The reviewer runs as the JOB container (steps exec inside it), not a
# `uses: docker://` step: a job container image accepts ${{ }} expressions,
# while a `uses:` ref is parsed before any context exists. That lets the tag
# resolve per-run — inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the
# baked fallback — so bumping the reviewer image is a ONE-variable edit that
# reaches every consumer despite their cached workflow ref.
#
# Always point the variable at an immutable :sha-<short> tag, never :latest —
# act_runner caches :latest and often does NOT re-pull a moved one; a fresh
# unique tag forces the pull. NB: vars are editable at will — whoever can edit
# the owner's variables redirects every consumer's reviewer image (same blast
# radius as editing this file, but without a commit trail).
#
# Fallback pin: sha-b37cd09 — the provider-wide lens budget (PR #27) on top of
# the opencode CLI engine (PR #26) and the Gitea >= 1.27 workflow_call
# reclassification. Keep it current-ish when touching this file anyway.
container:
image: gitea.stevedudenhoeffer.com/steve/gadfly:${{ inputs.reviewer_tag || vars.GADFLY_REVIEWER_TAG || 'sha-b37cd09' }}
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-3095ebf
- name: Run the gadfly reviewer
run: /entrypoint.sh
env:
# --- event context (from the CALLER's github.*) -------------------
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
@@ -104,17 +142,25 @@ jobs:
# forwarding, since the auto token isn't a forwarded workflow_call secret.
GITEA_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number || inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
# --- provider auth (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
# OLLAMA_CLOUD_API_KEY powers both the ollama-cloud majordomo path AND
# the opencode engine (GADFLY_MODELS entry "opencode/<model>").
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
# Qwen (Alibaba Model Studio) and Kimi (Moonshot) over their own APIs,
# for GADFLY_MODELS entries like "qwen/qwen3.8-max". Each built-in
# reads ONLY its own variable — no cross-provider fallback — so a
# missing line here is a clean 401, never a silently mis-keyed call.
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Named LAN endpoints, defined in user/org vars (format
@@ -124,12 +170,14 @@ jobs:
# token, keep that one a secret instead.
GADFLY_ENDPOINT_M1: ${{ vars.GADFLY_ENDPOINT_M1 }}
GADFLY_ENDPOINT_M5: ${{ vars.GADFLY_ENDPOINT_M5 }}
# ragnaros = the 4090 Ti via its llama-swap proxy. Defined in the user
# var GADFLY_ENDPOINT_RAGNAROS (format "<provider>|<base-url>[|<key>]")
# so the URL can change without editing this file; the matching model is
# ragnaros/qwen3.6-27b in GADFLY_DEFAULT_MODELS. NB: use the un-hyphenated
# `llamaswap` provider spelling in the var — the pinned image needs it.
GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}
# netherstorm = the local GPU box via its llama-swap proxy. Defined in the
# user var GADFLY_ENDPOINT_NETHERSTORM (format "<provider>|<base-url>[|<key>]",
# e.g. "llamaswap|https://llama-swap.netherstorm...") so the URL can change
# without editing this file; the matching model is netherstorm/qwen3.6-27b in
# GADFLY_DEFAULT_MODELS. NB: use the un-hyphenated `llamaswap` provider
# spelling in the var — the pinned image needs it. Without this line the var
# is silently dropped at the workflow_call boundary ('unknown provider').
GADFLY_ENDPOINT_NETHERSTORM: ${{ vars.GADFLY_ENDPOINT_NETHERSTORM }}
# --- findings telemetry (optional) --------------------------------
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
@@ -138,7 +186,9 @@ jobs:
GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }}
GADFLY_PROVIDER: ${{ inputs.provider }}
GADFLY_BASE_URL: ${{ inputs.base_url }}
GADFLY_PROVIDER_CONCURRENCY: ${{ inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY }}
# NB: GADFLY_PROVIDER_CONCURRENCY (the old model cap) is intentionally no
# longer forwarded — entrypoint.sh ignores it. The lens budget is the one
# throttle now, shared across a provider's models.
GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY }}
GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }}
GADFLY_MAX_STEPS: ${{ inputs.max_steps }}
+18 -6
View File
@@ -34,7 +34,10 @@ verifies each one against the actual code, and posts its findings as a comment.
cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout)
main.go orchestration: fan specialists out (executus/fanout), each a review pass + recheck
engine.go reviewEngine abstraction: executus run.Executor (majordomo agent loop +
compaction/bounding/budget/critic) vs claude-code CLI shell-out
compaction/bounding/budget/critic) vs claude-code / opencode CLI shell-outs
opencode.go the opencode CLI engine: ollama-cloud model through the OpenCode harness
(read-only via a generated OPENCODE_CONFIG_CONTENT agent+provider); for
benchmarking the boutique harness vs a free one on the same model
executus.go executus wiring: tool.Registry over the repo tools, the run.Executor build
(compact + model context-limit threshold + per-PR budget + wrap-up critic)
specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution
@@ -54,7 +57,7 @@ Dockerfile multi-stage; private-module creds via BuildKit secrets ne
.gitea/workflows/build-image.yml push main → :latest; tag v* → :<tag>+:latest; PR → build-only
.gitea/workflows/review-reusable.yml reusable (workflow_call) review job; resolves swarm config at
RUNTIME: consumer `with:` input → owner user-scope var (GADFLY_DEFAULT_MODELS /
_SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, +
_SPECIALISTS / _PROVIDER_LENS_CONCURRENCY, +
GADFLY_ENDPOINT_RAGNAROS) → image default. Vars are injected per-run, so editing
one var retunes the whole fleet even though long-lived act_runners CACHE this file
by ref (a moved tag is NOT re-fetched — only a runtime value or a fresh @<sha>
@@ -151,10 +154,19 @@ are actually exercised. OpenAI/Anthropic/Google come from majordomo's abstractio
NOT re-pulled, so the job silently runs the previous image. For a run that must use a specific
build (e.g. validating a just-pushed fix), pin the consumer stub to the immutable
`:sha-<short>` tag the build publishes, not `:latest`.
- **Concurrency is per-provider** (`entrypoint.sh`): each provider is a lane, lanes run in
parallel, `cap` (from `GADFLY_PROVIDER_CONCURRENCY` else `GADFLY_CONCURRENCY`, default 1) bounds
models-at-once within a lane. The review timeout (`GADFLY_TIMEOUT_SECS`) is **per-lens**, not
shared across the suite — a slow model can't starve later lenses (the original timeout bug).
- **Concurrency is one per-provider lens budget** (`entrypoint.sh` + `cmd/gadfly/lenssem.go`):
each provider is a lane, lanes run in parallel, and within a lane ALL of the provider's models
run at once — the only throttle is a **provider-wide lens budget** (max lens passes in flight,
a lens = one specialist's review+recheck). The budget comes from
`GADFLY_PROVIDER_LENS_CONCURRENCY` (`provider=N` map) else `GADFLY_LENS_CONCURRENCY` (default 1).
Because models are separate processes, the budget is a **cross-process permit pool**: entrypoint
seeds a per-lane dir of N flock files; each model's binary acquires one before a lens pass and
releases it after (flock auto-drops on process death). This replaced the old two-level
`GADFLY_PROVIDER_CONCURRENCY` MODEL cap × per-model `GADFLY_LENS_CONCURRENCY`, which multiplied
and let a model hold its slot through its last lens — stalling the next model with idle lens
capacity. Those two model-cap vars are now **ignored**. The review timeout
(`GADFLY_TIMEOUT_SECS`) is **per-lens**, not shared across the suite — a slow lens can't starve
the others (the original timeout bug).
- **Large-PR token burn**: the agent loop re-sends the whole transcript every step, so a giant
diff (the old `get_diff` dumped it untruncated, and it was embedded in both the review and
recheck task) was re-transmitted ~steps × lenses × passes × models times — a ~250 K-token PR
+20
View File
@@ -33,6 +33,26 @@ RUN apk add --no-cache bash git curl jq ca-certificates nodejs npm procps
# CLI to the image (notably larger); ollama-only users pay the size but nothing
# else. Auth is provided at runtime via CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
RUN npm install -g @anthropic-ai/claude-code && npm cache clean --force
# Bundle the OpenCode CLI (opencode.ai) for the `opencode` review engine
# (GADFLY_MODELS=opencode/<model>): a freely-available agentic harness driving an
# ollama-cloud model, used to benchmark it against gadfly's own executus harness
# on the same model. Auth reuses OLLAMA_CLOUD_API_KEY at runtime. opencode ships a
# compiled (Bun) binary; it publishes musl variants (opencode-linux-*-musl) that
# npm auto-selects on alpine via the package "libc" field. libstdc++/libgcc are
# the Bun binary's runtime deps; gcompat is a belt-and-suspenders fallback in case
# npm ever resolves a glibc build here.
RUN apk add --no-cache gcompat libstdc++ libgcc \
&& npm install -g opencode-ai \
&& npm cache clean --force
# Best-effort: confirm the binary runs and pre-warm the openai-compatible provider
# package into opencode's cache so a review doesn't pay a first-run npm fetch. The
# warm-up model call intentionally fails against a dead URL. Never fail the build:
# a musl/runtime quirk here must not break the shared image for ollama/claude
# users — a broken opencode engine degrades to a normal (advisory) pass error.
RUN opencode --version >/dev/null 2>&1 \
&& OPENCODE_CONFIG_CONTENT='{"provider":{"gadfly":{"npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://127.0.0.1:9/v1"},"models":{"x":{}}}}}' \
timeout 120 opencode run --model gadfly/x "warm" >/dev/null 2>&1 \
; true
COPY --from=build /out/gadfly /usr/local/bin/gadfly
COPY scripts /app/scripts
COPY entrypoint.sh /entrypoint.sh
+116 -32
View File
@@ -73,11 +73,43 @@ majordomo failover chain / alias) is used verbatim.
| **[llama-swap](https://github.com/mostlygeek/llama-swap)** (model-swapping proxy) | `llama-swap`/`llama-swaps` (un-hyphenated `llamaswap`/`llamaswaps` also accepted) + `GADFLY_BASE_URL` or a `GADFLY_ENDPOINT_*` entry, or an `LLM_*` `llama-swap://` / `llama-swaps://` DSN | optional bearer | ⚠️ wired, **untested** |
| **OpenAI-compatible** (incl. local Ollama's `/v1`) | `openai` + `GADFLY_BASE_URL` | `OPENAI_API_KEY` (any non-empty for Ollama) | ✅ tested against Ollama |
| **OpenAI** | `openai` | `OPENAI_API_KEY` | ⚠️ wired, **untested** |
| **Qwen** (Alibaba Model Studio) | `qwen` | `QWEN_API_KEY` | ⚠️ wired, **untested** |
| **Kimi** (Moonshot) | `kimi` | `KIMI_API_KEY` | ⚠️ wired, **untested** |
| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** |
| **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** |
Qwen and Kimi are majordomo built-ins that speak the OpenAI protocol at their own
endpoints, so `qwen/qwen3.8-max` or `kimi/kimi-k2-0711-preview` work as
`GADFLY_MODELS` entries with only the matching key set. Each reads **only** its own
variable — no cross-provider fallback — so forgetting to forward `QWEN_API_KEY`
gets you a skip notice naming it, not a mis-keyed call. Note `kimi/<model>` (Moonshot's
API, `KIMI_API_KEY`) is a different route than the `kimi-k2.6:cloud` entry in the
default swarm, which is Ollama Cloud and keyed by `OLLAMA_CLOUD_API_KEY`.
> **Qwen keys are endpoint-scoped, and the failure looks like a bad key.**
> Alibaba Model Studio issues *workspace-scoped* endpoints of the form
> `https://<workspace>.<region>.maas.aliyuncs.com/compatible-mode/v1`. A key
> issued for one host is rejected by another with a genuine
> `401 Incorrect API key provided` — so a perfectly good key reads as invalid if
> the endpoint doesn't match. The built-in defaults to the shared international
> host; point at your own with a named endpoint, which needs no code change:
>
> ```
> GADFLY_ENDPOINT_QWENWS = "qwen|https://<workspace>.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
> GADFLY_MODELS = "qwenws/qwen3.8-max,..."
> QWEN_API_KEY = <secret>
> ```
>
> **Leave the key out of the endpoint var.** `GADFLY_ENDPOINT_*` are Gitea
> *variables*, which are not masked in logs; the third `|<key>` field would put
> a credential there. Omit it and a `qwen`/`kimi` endpoint falls back to its own
> `QWEN_API_KEY` / `KIMI_API_KEY` secret — its own vendor's key, never another's.
>
> (Verified the hard way against a live deployment.)
> ### 🧪 Honest status
> Only the **Ollama** paths above are actually exercised. The OpenAI / Anthropic / Google
> Only the **Ollama** paths above are actually exercised. The OpenAI / Qwen / Kimi /
> Anthropic / Google
> providers come "for free" from majordomo's abstraction and *should* work, but I haven't
> spent money verifying them — treat them as untested. The OpenAI-**compatible** path **is**
> tested, because you can point it at a local Ollama (`GADFLY_BASE_URL=http://localhost:11434/v1`)
@@ -140,6 +172,58 @@ as an example, not wired or tested here.
> specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped
> with this engine (Claude Code does its own legwork).
### OpenCode engine (`opencode`)
The same shell-out idea, but with a **freely-available** harness: Gadfly can review through the
**[OpenCode](https://opencode.ai) CLI**, which — like Claude Code — brings its own read tools and
verifies findings against the checked-out repo, but drives an **ollama-cloud** model. The point is
to benchmark gadfly's boutique executus harness against a good open harness *on the same model*:
run `ollama-cloud/glm-5.2` (majordomo loop) and `opencode/glm-5.2` (OpenCode) side by side and
compare their findings. This is the wired, no-proxy version of the "alternate backends" comparison
described above. The CLI is bundled in the image (Node + `opencode-ai`).
Select it as a model id:
| Spec | Meaning |
|------|---------|
| `opencode/glm-5.2` | serve `glm-5.2` via ollama-cloud through OpenCode |
| `open-code/glm-5.2` | accepted alias spelling (`opencode` is canonical) |
| `opencode/qwen3-coder:480b-cloud` | model ids are taken **verbatim** — colons are preserved (no `:thinking` suffix here, unlike claude-code) |
| `opencode/<provider>/<model>` | escape hatch: pass `<provider>/<model>` straight to OpenCode's own provider registry/auth (e.g. `opencode/anthropic/claude-sonnet-4-6`) |
| `opencode` | bare: OpenCode's configured default model |
```yaml
GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2" # the benchmark pairing
```
Auth reuses **`OLLAMA_CLOUD_API_KEY`** (the same secret the ollama-cloud path uses; it's mapped to
`OLLAMA_API_KEY`, which the generated provider references as `{env:OLLAMA_API_KEY}` — never a literal
secret in config). Tuning knobs (all optional):
| Env | Default | Meaning |
|-----|---------|---------|
| `GADFLY_OPENCODE_MODEL` | *(from the spec suffix)* | overrides the model |
| `GADFLY_OPENCODE_BASE_URL` | `https://ollama.com/v1` | ollama-cloud endpoint; point at a local Ollama (`http://localhost:11434/v1`) or any OpenAI-compatible server |
| `GADFLY_OPENCODE_EXTRA_ARGS` | *(unset)* | extra `opencode run` args, **whitespace-split**, appended before the positional task |
| `GADFLY_OPENCODE_BIN` | `opencode` | CLI binary path |
> **Read-only is enforced through config, not a flag.** OpenCode has no `--append-system-prompt`, so
> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with the
> mutating and network tools (`edit`/`bash`/`webfetch`/`websearch`/`external_directory`) denied at both
> the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`. That env var is the
> highest-precedence config source in the container, so it **outranks any `opencode.json` a reviewed
> repo ships** — a repo can't re-enable edits on the reviewer. The subprocess runs with a **reduced
> environment**: the provider keys OpenCode needs to authenticate (`OLLAMA_API_KEY` for the primary
> path, plus `ANTHROPIC_*`/`OPENAI_*`/`GOOGLE_*`/`GEMINI_*` for the `opencode/<provider>/<model>`
> pass-through) alongside `PATH`/`HOME`/locale/`OPENCODE_*`/`GADFLY_OPENCODE_*` — but **not** gadfly's
> own secrets (the Gitea token, the findings token, or the claude-code subscription token), which the
> CLI has no use for.
> **Newly wired, lightly tested.** Like the claude-code engine, `auto` specialist selection and the
> `delegate_investigation` worker are majordomo-only and are skipped here (OpenCode does its own
> legwork). Output capture reads OpenCode's default text output, so treat the engine as new and
> sanity-check a run before trusting a benchmark.
### Endpoint aliases via env vars
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
@@ -220,38 +304,32 @@ Unset = no delegation (current behavior).
### Concurrency (per-provider lanes)
With multiple models, each **provider** is its own lane and lanes run in **parallel**, so a fast
cloud provider isn't stuck behind a slow local box. Within a lane, at most `cap` models run at
once — `cap` comes from `GADFLY_PROVIDER_CONCURRENCY` (a `provider=N` map) else `GADFLY_CONCURRENCY`
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow model on one lens
can't starve the others.
cloud provider isn't stuck behind a slow local box. There is **one throttle**: a per-provider
**lens budget** — the max number of lens passes (a lens = one specialist's review+recheck) in
flight at once for that provider. Every model in the lane runs concurrently and its lenses draw
from that single shared budget, so nothing else caps how many models run. The budget comes from
`GADFLY_PROVIDER_LENS_CONCURRENCY` (a `provider=N` map) else the `GADFLY_LENS_CONCURRENCY` scalar
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow lens can't starve
the others.
```yaml
# One local box (serial — it serves one model at a time) + 3 cloud reviews at once,
# both lanes running concurrently:
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=3,m1pro=1"
# The local box gets 1 lens at a time (serial); the cloud lane runs up to 3 lens passes at once,
# shared across ALL its models. Both lanes run concurrently.
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1pro=1"
GADFLY_MODELS: "m1pro/qwen3:14b,qwen3-coder:480b-cloud,gpt-oss:120b-cloud"
```
A model's provider is the spec's first segment (`m1pro/…``m1pro`), or `GADFLY_PROVIDER`/
`ollama-cloud` for a bare id. Default (`cap 1`) keeps a single-provider pool fully sequential.
`ollama-cloud` for a bare id. The budget is **shared across the provider's models**: with a
budget of 3 and two cloud models, you get 3 lens passes in flight in any mix — as one model
finishes a lens, the freed slot immediately goes to another model's next lens, so a model
winding down to its last lens never stalls the others (the pre-2026-07 design capped *models*
separately and did stall — that `GADFLY_PROVIDER_CONCURRENCY`/`GADFLY_CONCURRENCY` model cap is
**gone**; those vars are now ignored). Default (budget `1`) keeps a provider fully sequential.
**Lens fan-out (within a model).** By default the specialist lenses run **sequentially** inside
each model (`GADFLY_LENS_CONCURRENCY=1`). Raise it to overlap the independent per-lens
review+recheck passes — the model then posts its consolidated comment as soon as its lenses
finish (so with sequential models, results stream in per model and per-model timings stay
clean). Like the model cap, it's **per-provider configurable**: `GADFLY_PROVIDER_LENS_CONCURRENCY`
takes a `provider=N` map keyed by the **same provider lanes** as `GADFLY_PROVIDER_CONCURRENCY`,
falling back to the `GADFLY_LENS_CONCURRENCY` scalar (default `1`). **It multiplies with the
model cap:** total in-flight requests ≈ *models-at-once × lenses-at-once*, so to fan lenses out
without oversubscribing a backend, keep its model cap low and raise its lens cap:
```yaml
# Per provider: cloud runs one model at a time but fans its 3 lenses out (3 concurrent requests);
# the slow local box stays fully serial. Both provider lanes still run in parallel.
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
GADFLY_SPECIALISTS: "security,correctness,error-handling"
```
> Under the hood the shared budget is a small cross-process permit pool (flock files, seeded per
> lane by `entrypoint.sh`); permits release automatically if a model process dies, so a crashed
> lens can't leak budget.
### Live status board
@@ -355,6 +433,12 @@ pinned version (plus `:latest`). Pin full-stub consumers to a `:vN` image tag fo
(`@v1`) or `@main` is often **not** re-fetched and silently runs a stale copy. A fresh `@<sha>` is the
only reliable way to roll out a *structural* change to the reusable.
Structural changes are the rare case, though: the reviewer **image tag** the reusable runs resolves at
runtime (`reviewer_tag` input → user var `GADFLY_REVIEWER_TAG` → the fallback pin baked into the
reusable), so a routine Gadfly release is *build the image → update `GADFLY_REVIEWER_TAG` to the new
`sha-<short>`* — every pinned consumer picks it up on its next review, no re-pin. Always point the
variable at an immutable `sha-` tag, never `:latest` (the runner caches `:latest`).
### Central config via variables
So you don't have to re-pin every consumer just to retune the swarm, the reusable resolves its config
@@ -364,10 +448,10 @@ on its next review **without** a re-pin or a tag move:
| Variable (user/org scope) | Sets |
|---|---|
| `GADFLY_REVIEWER_TAG` | the reviewer **image tag** the reusable runs (e.g. `sha-b37cd09`); empty ⇒ the fallback pin baked into the reusable |
| `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) |
| `GADFLY_DEFAULT_SPECIALISTS` | the lens suite |
| `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` | models-at-once per provider |
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | lenses-at-once per provider |
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | the per-provider lens budget (lens passes in flight per provider, shared across its models) |
| `GADFLY_ENDPOINT_RAGNAROS` | a named endpoint, e.g. `llamaswap\|https://host` |
Adding a *new* named endpoint still needs a one-line reusable edit (Gitea can't auto-expose arbitrary
@@ -384,14 +468,14 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults):
| `GADFLY_BASE_URL` | — | override endpoint (OpenAI/Ollama-compatible servers) |
| `GADFLY_API_KEY` | — | provider key; falls back to the provider's standard env |
| `claude-code` model id | — | route a model through the bundled Claude Code CLI (`claude-code` / `claude-code/<model>`); see [Claude Code engine](#claude-code-engine-claude-code) for its `GADFLY_CLAUDE_*` knobs |
| `opencode` model id | — | route an ollama-cloud model through the bundled OpenCode CLI (`opencode/<model>`); see [OpenCode engine](#opencode-engine-opencode) for its `GADFLY_OPENCODE_*` knobs |
| `GADFLY_SPECIALISTS` | default suite | csv of lenses, `all`, or `auto` (dynamic selection) |
| `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode |
| `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
| `GADFLY_WORKER_MAX_STEPS` | 8 | tool-step cap for a delegated worker run |
| `GADFLY_CONCURRENCY` | 1 | default max models run at once **per provider** |
| `GADFLY_PROVIDER_CONCURRENCY` | — | per-provider overrides, e.g. `ollama-cloud=3,m1pro=1` |
| `GADFLY_LENS_CONCURRENCY` | 1 | specialist lenses run at once **within a model** (× model cap = total in-flight) |
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens overrides, same lanes as `GADFLY_PROVIDER_CONCURRENCY`, e.g. `ollama-cloud=3,m1=1` |
| `GADFLY_LENS_CONCURRENCY` | 1 | **per-provider lens budget** — lens passes in flight per provider, shared across all its models (all a provider's models run at once; this is the only throttle) |
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens-budget overrides, a `provider=N` map, e.g. `ollama-cloud=3,m1=1` |
| `GADFLY_CONCURRENCY` / `GADFLY_PROVIDER_CONCURRENCY` | | **removed** (was the per-provider models-at-once cap; now ignored — the lens budget is the single throttle) |
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap |
| `GADFLY_TIMEOUT_SECS` | 300 | deadline **per specialist lens** (review+recheck) |
| `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass |
+34 -21
View File
@@ -19,16 +19,19 @@ import (
// the model's text answer. It is the one primitive both review passes use — the
// draft review and the adversarial recheck — so the rest of the pipeline
// (specialist composition, recheck orchestration, consolidation, emit) is
// engine-agnostic. Two implementations:
// engine-agnostic. Three implementations:
//
// - majordomoEngine: the original path — a majordomo tool-using agent loop
// (read_file/grep/… over a sandboxed repoFS).
// - claudeCodeEngine: shells out to the `claude` CLI in print mode, which
// brings its OWN repo tools; gadfly just feeds it the prompt and reads back
// the final text.
// - openCodeEngine (opencode.go): shells out to the `opencode` CLI likewise,
// but driving an ollama-cloud model — for benchmarking the two harnesses on
// the same model.
//
// maxSteps is the tool-step budget for engines that have one (majordomo); the
// claude-code engine manages its own loop and ignores it.
// shell-out engines manage their own loop and ignore it.
type reviewEngine interface {
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
}
@@ -157,16 +160,7 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
// Force an extended-thinking budget for this run (a "...:max" spec).
cmd.Env = append(cmd.Env, "MAX_THINKING_TOKENS="+strconv.Itoa(e.thinkingTokens))
}
// Put the CLI and the Node children it spawns in their own process group and
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
// orphaned claude/node processes behind in the container.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
killGroupOnCancel(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -217,13 +211,39 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
return "", fmt.Errorf("claude -p produced no parseable output")
}
// killGroupOnCancel puts cmd in its own process group and, on context cancel,
// SIGKILLs the whole group — so a timed-out shell-out CLI (claude/opencode) can't
// leave orphaned Node children behind in the container. Call before cmd.Run.
func killGroupOnCancel(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
// filterEnv returns the current process environment reduced to the variables for
// which keep returns true. Shared by the shell-out engines' minimal-env builders
// (claudeEnv, openCodeEnv), which differ only in their keep predicate.
func filterEnv(keep func(string) bool) []string {
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
}
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
// the CLI needs (PATH/HOME, its auth tokens, locale, Node/XDG/GADFLY_CLAUDE_*
// knobs), deliberately dropping the rest of the runner's secrets — GITEA_TOKEN,
// GADFLY_FINDINGS_TOKEN, provider keys — so they never reach the third-party
// CLI. Defense in depth: the parent already holds them, but the CLI has no need.
func claudeEnv() []string {
keep := func(k string) bool {
return filterEnv(func(k string) bool {
switch k {
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
return true
@@ -234,14 +254,7 @@ func claudeEnv() []string {
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_")
}
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
})
}
// truncateForErr caps CLI error detail so a stderr dump can't bloat the comment,
+1 -1
View File
@@ -156,7 +156,7 @@ func TestRunSpecialists_PerProviderFanOut(t *testing.T) {
// TestLensConcurrency covers the resolution matrix: scalar default, scalar
// override, and per-provider override keyed by the model's resolved lane (same
// lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY).
// lane rule entrypoint.sh uses for GADFLY_PROVIDER_LENS_CONCURRENCY).
func TestLensConcurrency(t *testing.T) {
tests := []struct {
name string
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
)
// lensSem is the PROVIDER-WIDE lens-permit pool. Historically each model's binary
// throttled its own lenses in-process (GADFLY_LENS_CONCURRENCY) while entrypoint.sh
// separately capped how many MODELS ran at once — two multiplicative gates in
// different processes. That let a model hold its whole slot until its last lens
// finished, stalling the next model even with idle lens capacity.
//
// Instead, entrypoint.sh now runs all of a provider's models concurrently and
// seeds ONE directory of N permit files per provider (N = the provider's lens
// budget). Every model process in that lane draws from the same pool: a lens pass
// (review + recheck) acquires a permit before it runs and releases it after, so a
// model winding down immediately yields its freed permits to another model's
// queued lenses. Permits are held with flock, which the kernel drops when the
// holding process exits — so a killed/crashed model frees its permits for free.
type lensSem struct {
dir string
size int
}
// lensSemPollInterval is how often a blocked acquirer re-sweeps the permit files.
// Lens passes run for many seconds to minutes, so a coarse poll adds negligible
// latency while keeping the mechanism a few lines of stdlib (no IPC primitives).
const lensSemPollInterval = 150 * time.Millisecond
// activeLensSem returns the shared semaphore configured by entrypoint.sh, or nil
// when it isn't set (standalone/local runs, tests, or an older entrypoint) — in
// which case runSpecialists falls back to the in-process fanout limit alone, i.e.
// the pre-existing per-model behavior.
func activeLensSem() *lensSem {
dir := os.Getenv("GADFLY_LENS_SEM_DIR")
if dir == "" {
return nil
}
n := envInt("GADFLY_LENS_SEM_SIZE", 0)
if n < 1 {
// A dir was requested but the size is missing/blank/invalid. Rather than
// silently run unthrottled, say so on stderr — it's almost certainly a
// misconfiguration in entrypoint.sh (the two are set together).
fmt.Fprintf(os.Stderr, "gadfly: GADFLY_LENS_SEM_DIR set but GADFLY_LENS_SEM_SIZE=%q invalid; lenses run unthrottled\n", os.Getenv("GADFLY_LENS_SEM_SIZE"))
return nil
}
return &lensSem{dir: dir, size: n}
}
// acquire blocks until a permit is free (or ctx is done) and returns a release
// func. It FAILS OPEN: if the pool directory is structurally unusable (missing,
// unwritable, or on a filesystem without flock) it logs once and returns a no-op
// release with a nil error, so the lens runs UNTHROTTLED rather than hanging the
// whole review forever — this is an advisory reviewer, a slightly oversubscribed
// backend beats a stuck one. Only a full-but-healthy pool actually blocks; on ctx
// cancellation it returns a no-op release plus ctx.Err() so the caller can surface
// the lens as "did not run" instead of leaking a permit.
func (s *lensSem) acquire(ctx context.Context) (func(), error) {
for {
release, ok, err := s.tryAcquire()
if ok {
return release, nil
}
if err != nil {
fmt.Fprintf(os.Stderr, "gadfly: lens permit pool %q unusable (%v); proceeding without a permit\n", s.dir, err)
return func() {}, nil
}
timer := time.NewTimer(lensSemPollInterval)
select {
case <-ctx.Done():
timer.Stop() // don't leak the timer when we bail on cancellation
return func() {}, ctx.Err()
case <-timer.C:
}
}
}
// tryAcquire makes one non-blocking sweep over the permit files. It returns a
// release func for the first one it locks; otherwise it distinguishes a healthy
// FULL pool (some permit was openable but flock-busy → ok=false, err=nil → keep
// polling) from a structurally BROKEN pool (no permit was even acquirable and none
// was merely busy → err set → caller fails open). Permit files are created lazily
// (entrypoint.sh only guarantees the directory exists). Closing the *os.File in
// the returned func drops the flock (the lock lives on the open file description).
func (s *lensSem) tryAcquire() (func(), bool, error) {
sawBusy := false
var structural error
for i := 0; i < s.size; i++ {
path := filepath.Join(s.dir, fmt.Sprintf("permit.%d", i))
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
structural = err // e.g. the pool dir is gone or unwritable
continue
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
return func() { f.Close() }, true, nil
} else if errors.Is(err, syscall.EWOULDBLOCK) {
sawBusy = true // this permit is held by someone else — pool has capacity
f.Close()
} else {
structural = err // flock unsupported / other → not a transient "busy"
f.Close()
}
}
if sawBusy {
return nil, false, nil // full but healthy: another lens will free a permit
}
return nil, false, structural
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"context"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// flock permits are per open-file-description, so two separate opens of the same
// permit file conflict even within one process — the exhaustion, blocking, and
// max-in-flight tests below therefore exercise the same semantics a real
// multi-process lane would see.
func TestActiveLensSemUnset(t *testing.T) {
t.Setenv("GADFLY_LENS_SEM_DIR", "")
if s := activeLensSem(); s != nil {
t.Fatalf("expected nil sem when GADFLY_LENS_SEM_DIR unset, got %+v", s)
}
// A dir with a zero/blank size is inert too (must not divide the pool by 0).
t.Setenv("GADFLY_LENS_SEM_DIR", t.TempDir())
t.Setenv("GADFLY_LENS_SEM_SIZE", "0")
if s := activeLensSem(); s != nil {
t.Fatalf("expected nil sem when size < 1, got %+v", s)
}
}
func TestLensSemExhaustionAndRelease(t *testing.T) {
s := &lensSem{dir: t.TempDir(), size: 2}
r1, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("first acquire should succeed: ok=%v err=%v", ok, err)
}
r2, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("second acquire should succeed: ok=%v err=%v", ok, err)
}
// Pool full but healthy: ok=false with NO error (keep polling), not a
// structural failure.
if _, ok, err := s.tryAcquire(); ok || err != nil {
t.Fatalf("third acquire should be full-but-healthy: ok=%v err=%v", ok, err)
}
r1() // free one permit
r3, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("acquire should succeed after a release: ok=%v err=%v", ok, err)
}
r2()
r3()
}
// A structurally broken pool (dir missing/unwritable) must FAIL OPEN — tryAcquire
// surfaces the error and acquire returns promptly with a no-op release and no
// error, so a lens runs unthrottled instead of spinning forever.
func TestLensSemBrokenPoolFailsOpen(t *testing.T) {
s := &lensSem{dir: filepath.Join(t.TempDir(), "does", "not", "exist"), size: 2}
if _, ok, err := s.tryAcquire(); ok || err == nil {
t.Fatalf("tryAcquire on a broken pool should report a structural error: ok=%v err=%v", ok, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
release, err := s.acquire(ctx)
if err != nil {
t.Fatalf("acquire should fail open (nil err), got %v", err)
}
if waited := time.Since(start); waited > time.Second {
t.Fatalf("acquire on a broken pool should return promptly, waited %v", waited)
}
release() // must be a safe no-op
}
func TestLensSemAcquireBlocksThenCancels(t *testing.T) {
s := &lensSem{dir: t.TempDir(), size: 1}
// Immediate success while a permit is free.
release, err := s.acquire(context.Background())
if err != nil {
t.Fatalf("acquire on a free pool: %v", err)
}
// With the only permit held, acquire must block until ctx expires.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := s.acquire(ctx); err == nil {
t.Fatal("acquire on an exhausted pool should return ctx error, not a permit")
}
if waited := time.Since(start); waited < 50*time.Millisecond {
t.Fatalf("acquire returned too fast (%v); it should have blocked on the full pool", waited)
}
release()
}
func TestLensSemNeverExceedsSize(t *testing.T) {
const size = 3
s := &lensSem{dir: t.TempDir(), size: size}
var inFlight, peak int64
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
release, err := s.acquire(context.Background())
if err != nil {
t.Errorf("acquire: %v", err)
return
}
defer release()
n := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if n <= p || atomic.CompareAndSwapInt64(&peak, p, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
atomic.AddInt64(&inFlight, -1)
}()
}
wg.Wait()
if peak > size {
t.Fatalf("max in-flight lens permits = %d, exceeds budget %d", peak, size)
}
if peak == 0 {
t.Fatal("no permits were ever acquired")
}
}
+77 -44
View File
@@ -22,7 +22,11 @@
//
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
// alias / failover chain (required). A bare id is
// prefixed with GADFLY_PROVIDER.
// prefixed with GADFLY_PROVIDER. Two prefixes select a
// shell-out CLI engine instead of the in-process loop:
// "claude-code/<model>" (Claude Code, see engine.go) and
// "opencode/<model>" (OpenCode over ollama-cloud, see
// opencode.go) — both bring their own repo tools.
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
// e.g. "ollama" for a local daemon, "openai", …).
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
@@ -45,15 +49,18 @@
// GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on).
// GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16).
// GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300).
// GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently within this
// model (optional, default 1 = sequential). Total in-flight
// model requests ≈ this × entrypoint.sh's per-provider model
// concurrency, so keep the product within the backend's budget.
// GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently (optional,
// default 1 = sequential). Under entrypoint.sh this is the
// PROVIDER-WIDE lens budget, shared across all of that
// provider's models via a permit pool (GADFLY_LENS_SEM_DIR),
// so it bounds total lens passes in flight per provider.
// GADFLY_PROVIDER_LENS_CONCURRENCY per-provider override for the above, as a
// "provider=N,provider=N" map keyed by the SAME provider
// lanes as GADFLY_PROVIDER_CONCURRENCY (e.g.
// "ollama-cloud=3,m1=1"). Wins over GADFLY_LENS_CONCURRENCY
// for the model's provider; falls back to it otherwise.
// "provider=N,provider=N" map keyed by the provider lanes
// (e.g. "ollama-cloud=3,m1=1"). Wins over
// GADFLY_LENS_CONCURRENCY for the model's provider.
// GADFLY_LENS_SEM_DIR / GADFLY_LENS_SEM_SIZE set by entrypoint.sh: the shared
// cross-process lens-permit pool (dir of N flock files)
// and its size. Unset => in-process lensConcurrency only.
// GADFLY_MAX_DIFF_CHARS diff chars embedded in the review prompt (optional, default 60000;
// the full diff is reachable via the paginated get_diff tool).
//
@@ -90,9 +97,11 @@ const (
// calls and then hard-failing with "max steps reached without a final
// answer" — it always has a few steps left to wrap up.
defaultWrapUpReserve = 4
// defaultLensConcurrency is how many specialist lenses run at once within a
// single model. 1 keeps the suite sequential (the historical behavior);
// higher values overlap the independent per-lens passes. See runSpecialists.
// defaultLensConcurrency is the fallback lens budget when neither
// GADFLY_PROVIDER_LENS_CONCURRENCY nor GADFLY_LENS_CONCURRENCY is set. 1 keeps
// the suite sequential (the historical behavior); higher values overlap the
// independent per-lens passes. Under entrypoint.sh the resolved value is the
// provider-wide budget shared across the provider's models. See runSpecialists.
defaultLensConcurrency = 1
)
@@ -147,15 +156,18 @@ func run() error {
return err
}
// Resolve the review engine. The claude-code engine shells out to the
// `claude` CLI (its own repo tools); every other spec is a majordomo model.
// auto-selection and the delegate worker are majordomo-only — with
// claude-code they're skipped (Claude Code does its own legwork).
ccSpec := isClaudeCodeSpec(os.Getenv("GADFLY_MODEL"))
// Resolve the review engine. The shell-out engines (claude-code, opencode)
// bring their OWN repo tools; every other spec is an in-process majordomo
// model. auto-selection and the delegate worker are majordomo-only — with a
// shell-out engine they're skipped (the CLI does its own legwork).
spec := os.Getenv("GADFLY_MODEL")
var eng reviewEngine
if ccSpec {
eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root)
} else {
switch {
case isClaudeCodeSpec(spec):
eng = newClaudeCodeEngine(spec, fsTools.root)
case isOpenCodeSpec(spec):
eng = newOpenCodeEngine(spec, fsTools.root)
default:
mdl, merr := resolveModel()
if merr != nil {
return fmt.Errorf("resolve model: %w", merr)
@@ -187,13 +199,15 @@ func run() error {
}
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
// Majordomo-only — the selector is an llm.Model.
// Majordomo-only — the selector is an llm.Model, so a shell-out engine
// (claude-code, opencode) can't provide one; fall back to the default suite.
if auto {
if ccSpec {
fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite")
md, ok := eng.(*majordomoEngine)
if !ok {
fmt.Fprintln(os.Stderr, "gadfly: auto-select requires an in-process model engine; using the default suite")
specialists = suiteFromRegistry(registry, defaultSuite)
} else {
selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl)
selector, serr := resolveSelectorModel(md.mdl)
if serr != nil {
return fmt.Errorf("resolve selector model: %w", serr)
}
@@ -233,25 +247,34 @@ func run() error {
// runSpecialists reviews the diff through each lens and returns the results in
// the SAME order as specialists, regardless of finish order. It uses executus's
// fanout primitive: up to GADFLY_LENS_CONCURRENCY lenses run concurrently (the
// default of 1 keeps the suite sequential, exactly as before), and fanout.Run
// returns one result per lens in input order. Each lens already runs under its
// own per-lens timeout (reviewWithSpecialist) and the lenses only read the
// immutable repoFS, so concurrency simply overlaps independent passes.
// fanout primitive to overlap independent lens passes (fanout.Run returns one
// result per lens in input order); each lens runs under its own per-lens timeout
// (reviewWithSpecialist) and the lenses only read the immutable repoFS.
//
// Caution: this fans out WITHIN one model. It multiplies with entrypoint.sh's
// per-provider model concurrency, so total concurrent backend requests ≈
// (models at once) × (lenses at once). To fan lenses out without oversubscribing
// the backend, run models one at a time (provider lane cap 1) and raise this.
// Throttling: when entrypoint.sh runs several of a provider's models at once it
// seeds a shared lens-permit pool (activeLensSem) that every model's lenses draw
// from, so the real cap is total lens passes in flight per PROVIDER — not
// (models at once) × (lenses at once). Absent that pool (local runs, tests) the
// in-process GADFLY_LENS_CONCURRENCY limit applies alone (default 1 = sequential).
func runSpecialists(eng reviewEngine, base string, specialists []Specialist, task, diff string) []specialistResult {
// Optional live status board: publishes this model's per-lens progress to a
// file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE
// is set, so plain runs are unaffected.
sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists)
// The cross-process pool (if any) is the real ceiling; size the in-process
// fanout to it so a lone model in its lane can use the whole provider budget,
// while extra goroutines simply block in sem.acquire until a permit frees.
// Absent a pool, fall back to the in-process lens limit.
sem := activeLensSem()
maxConcurrent := lensConcurrency()
if sem != nil {
maxConcurrent = sem.size // the shared pool is the real ceiling
}
fanResults := fanout.Run(context.Background(), specialists, fanout.Options[Specialist]{
MaxConcurrent: lensConcurrency(),
}, func(_ context.Context, sp Specialist) (res specialistResult, _ error) {
MaxConcurrent: maxConcurrent,
}, func(ctx context.Context, sp Specialist) (res specialistResult, _ error) {
// A panic in one lens must not crash the whole binary (which would kill
// every other lens's output) or leave this lens stuck at "running" on the
// status board. fanout does not recover fn panics, so we do it here:
@@ -262,6 +285,17 @@ func runSpecialists(eng reviewEngine, base string, specialists []Specialist, tas
sw.set(sp.Name, lensFinished, "", true)
}
}()
// Hold a provider-wide permit for this lens's whole review+recheck pass.
// While waiting the lens stays "queued" on the board; cancellation before
// a permit frees surfaces as a did-not-run lens rather than a leaked slot.
if sem != nil {
release, err := sem.acquire(ctx)
if err != nil {
sw.set(sp.Name, lensFinished, "", true)
return specialistResult{spec: sp, out: fmt.Sprintf("⚠️ This reviewer did not run: %v", err), verdict: verdictUnknown, errored: true}, nil
}
defer release()
}
sw.set(sp.Name, lensRunning, "", false)
out, errored := reviewWithSpecialist(eng, base, sp, task, diff)
v := parseVerdict(out)
@@ -284,14 +318,13 @@ func runSpecialists(eng reviewEngine, base string, specialists []Specialist, tas
return results
}
// lensConcurrency resolves how many specialist lenses run at once for THIS run's
// model. It mirrors entrypoint.sh's per-provider MODEL concurrency: a
// per-provider override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...")
// wins for the model's provider, otherwise the GADFLY_LENS_CONCURRENCY scalar
// (default 1). The provider is resolved by modelProvider() — the SAME lane rule
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY — so e.g.
// "ollama-cloud=3,m1=1" fans cloud lenses out while keeping a slow local box
// serial, exactly the way the model map does for whole models.
// lensConcurrency resolves the lens budget for THIS run's provider: a per-provider
// override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...") wins for the
// model's provider (resolved by modelProvider()), otherwise the
// GADFLY_LENS_CONCURRENCY scalar (default 1). Under entrypoint.sh the SAME value
// seeds the shared cross-process permit pool (activeLensSem), so it is the
// provider-wide budget rather than a per-model one; standalone it caps the single
// model's in-process fanout.
func lensConcurrency() int {
if n, ok := providerOverride("GADFLY_PROVIDER_LENS_CONCURRENCY", modelProvider()); ok {
return n
@@ -301,7 +334,7 @@ func lensConcurrency() int {
// providerOverride parses a "provider=N,provider=N" env map and returns the
// value for provider when present and valid (>0). Mirrors entrypoint.sh's
// provider_cap lookup so the two concurrency maps share one syntax.
// provider_lens_cap lookup so the two share one syntax.
func providerOverride(envName, provider string) (int, bool) {
for _, item := range strings.Split(os.Getenv(envName), ",") {
k, v, ok := strings.Cut(item, "=")
+118 -22
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"slices"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
@@ -19,6 +20,89 @@ import (
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
const defaultProvider = "ollama-cloud"
// openAICompatProviders are the provider names that resolve to the plain
// openai client at an explicit base URL. openai-compatible is the generic
// spelling; kimi (Moonshot) and qwen (Alibaba Model Studio) are majordomo
// built-ins that ARE that client pointed elsewhere, so an explicit endpoint for
// either belongs on the same branch.
//
// One slice, because three places must agree: resolveModel's endpoint
// override, endpointProvider's GADFLY_ENDPOINT_* parser, and the test that
// pins them. A name accepted by one and rejected by another is a config that
// works when written one way and errors the other, for no reason a user could
// guess.
var openAICompatProviders = []string{"openai", "openai-compatible", "kimi", "qwen"}
// builtinCompatProviders are the openai-compat names that belong to a DIFFERENT
// vendor. openai.New defaults its credential to OPENAI_API_KEY, so any of these
// constructed without an explicit key would put an OpenAI key on the wire to
// Moonshot or Alibaba. Membership here means "pass the key unconditionally,
// even empty" — an absent key must be a 401, never a foreign credential.
var builtinCompatProviders = []string{"kimi", "qwen"}
func isOpenAICompatProvider(name string) bool {
return slices.Contains(openAICompatProviders, name)
}
// isBuiltinCompatProvider mirrors isOpenAICompatProvider rather than testing
// the slice inline, so both memberships are asked the same way.
func isBuiltinCompatProvider(name string) bool {
return slices.Contains(builtinCompatProviders, name)
}
// builtinCompatKeyEnv is the provider's own credential variable, matching the
// name majordomo's built-in reads on the registry path — so the same secret
// works whether or not an explicit endpoint is configured.
func builtinCompatKeyEnv(provider string) string {
return strings.ToUpper(strings.ReplaceAll(provider, "-", "_")) + "_API_KEY"
}
// openAICompatOptions builds the option set for an openai-compat provider, and
// is the ONE place the no-cross-vendor-fallback rule lives.
//
// Both resolution paths call it — resolveModel's GADFLY_BASE_URL override and
// endpointProvider's GADFLY_ENDPOINT_* parser. They had separate copies of this
// decision once, the guard was added to one of them, and the other kept leaking
// OPENAI_API_KEY to another vendor. keyHint names the variable to set when the
// key is absent, since the two paths take it from different places.
func openAICompatOptions(provider, baseURL, key, keyHint string) []openai.Option {
opts := []openai.Option{openai.WithBaseURL(baseURL)}
switch {
case isBuiltinCompatProvider(provider):
// With no explicit key, fall back to the provider's OWN variable
// (QWEN_API_KEY, KIMI_API_KEY). That is not the cross-vendor fallback
// this function exists to prevent — it is the same vendor's key — and
// it lets an operator keep the credential in a masked secret while the
// endpoint URL lives in a var, which is NOT masked.
//
// The hint always names that secret, never the caller's keyHint: on the
// GADFLY_ENDPOINT_* path the caller's is the endpoint variable, and
// pointing a keyless operator at it advises them to put a credential
// somewhere Gitea does not mask.
if key == "" {
key = os.Getenv(builtinCompatKeyEnv(provider))
}
opts = append(opts, openai.WithAPIKey(key), openai.WithAPIKeyName(builtinCompatKeyEnv(provider)))
case key != "":
opts = append(opts, openai.WithAPIKey(key))
// openai/openai-compatible with no explicit key keep openai.New's
// OPENAI_API_KEY default: for those names it IS the right key.
}
return opts
}
// endpointProviderNames is the operator-facing list of providers that accept an
// explicit endpoint. resolveModel and endpointProvider accept the SAME set, so
// one message serves both rather than each carrying a copy that drifts in
// order and spelling.
//
// Every accepted spelling belongs here, aliases included —
// TestEndpointProviderNamesAreAllAccepted asserts that each name listed
// actually resolves, so an omission fails the build rather than misleading an
// operator who is already debugging.
const endpointProviderNames = "openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/" +
"llama-swap/llama-swaps/llamaswap/llamaswaps/foreman/anthropic/google/gemini"
// resolveModel builds the review model from the environment. Gadfly is powered
// by majordomo, so it can target any provider majordomo supports — Ollama
// (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible
@@ -33,10 +117,12 @@ const defaultProvider = "ollama-cloud"
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, a remote Ollama, an OpenRouter-style gateway…).
// When set, the provider is constructed directly at that URL.
// GADFLY_API_KEY bearer/API key for the chosen provider. Optional; when
// unset the provider falls back to its standard env var
// (OLLAMA_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
// GOOGLE_API_KEY|GEMINI_API_KEY). Local Ollama needs none.
// GADFLY_API_KEY bearer/API key for the chosen provider, used ONLY on the
// GADFLY_BASE_URL override path. With no base URL the
// provider reads its own standard variable and this is never
// consulted: OLLAMA_API_KEY / OPENAI_API_KEY /
// QWEN_API_KEY / KIMI_API_KEY / ANTHROPIC_API_KEY /
// GOOGLE_API_KEY|GEMINI_API_KEY. Local Ollama needs none.
//
// With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so
// LLM_* env DSNs and registered aliases/tiers work too.
@@ -67,13 +153,17 @@ func resolveModel() (llm.Model, error) {
}
// Endpoint override: construct the provider directly at the given URL.
// The openai-compat family is matched by the shared predicate, not a
// repeated case list. The credential on THIS path is GADFLY_API_KEY; the
// built-ins' own KIMI_API_KEY / QWEN_API_KEY are read only on the registry
// path above, where GADFLY_BASE_URL is unset. The two paths never share a
// credential rule — assuming they do produces a config that passes every
// check and then 401s.
if isOpenAICompatProvider(provider) {
return openai.New(openAICompatOptions(provider, baseURL, apiKey, "GADFLY_API_KEY")...).Model(model)
}
switch provider {
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, openai.WithAPIKey(apiKey))
}
return openai.New(opts...).Model(model)
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
if apiKey != "" {
@@ -108,7 +198,7 @@ func resolveModel() (llm.Model, error) {
}
return google.New(opts...).Model(model)
default:
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use openai/openai-compatible/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use %s, or unset GADFLY_BASE_URL to resolve via majordomo)", provider, endpointProviderNames)
}
}
@@ -162,8 +252,8 @@ func buildSpec(provider, model string) string {
// entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL,
// else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one
// model per invocation, so this is that model's provider — used to resolve
// per-provider policy (e.g. lens concurrency) against the SAME provider keys
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY.
// per-provider policy (e.g. the lens budget) against the SAME provider keys
// entrypoint uses for GADFLY_PROVIDER_LENS_CONCURRENCY.
func modelProvider() string {
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if pfx, _, ok := strings.Cut(model, "/"); ok {
@@ -188,8 +278,10 @@ func modelProvider() string {
// plaintext local Ollama (or foreman queue) works:
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman"
// targets a foreman daemon (native Ollama on the wire):
// provider is ollama/openai/anthropic/google/foreman/llama-swap(s) or an
// openai-compat built-in (kimi, qwen) — endpointProviderNames is the
// authoritative list. "foreman" targets a foreman daemon (native Ollama
// on the wire):
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
//
// GADFLY_ALIAS_<NAME> = "<majordomo spec>"
@@ -240,6 +332,16 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
return nil, fmt.Errorf("missing base URL in %q", raw)
}
// Same shared predicate as resolveModel: the two must accept an identical
// set, and a hand-copied case list cannot guarantee that.
if isOpenAICompatProvider(provider) {
// The key for a named endpoint comes from the third DSN field, so that
// is what an absent one points at.
opts := append([]openai.Option{openai.WithName(name)},
openAICompatOptions(provider, baseURL, key, "GADFLY_ENDPOINT_"+strings.ToUpper(name))...)
return openai.New(opts...), nil
}
switch provider {
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
@@ -258,12 +360,6 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
// its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman://
// DSN, the base URL here is verbatim, so a plaintext http:// foreman works.
return ollama.Foreman(baseURL, key, ollama.WithName(name)), nil
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
return openai.New(opts...), nil
case "anthropic":
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
if key != "" {
@@ -277,6 +373,6 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
}
return google.New(opts...), nil
default:
return nil, fmt.Errorf("unknown provider %q (use ollama/llama-swap(s)/foreman/openai/openai-compatible/anthropic/google)", provider)
return nil, fmt.Errorf("unknown provider %q (use %s)", provider, endpointProviderNames)
}
}
+293 -1
View File
@@ -1,6 +1,17 @@
package main
import "testing"
import (
"context"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
func TestEndpointProvider(t *testing.T) {
t.Run("ollama http endpoint registers under its name", func(t *testing.T) {
@@ -68,6 +79,70 @@ func TestEndpointProvider(t *testing.T) {
}
}
// TestOpenAICompatProvidersResolveOnBothPaths pins the two provider switches
// together. kimi and qwen are majordomo built-ins that ARE the openai client at
// a different base URL, and two independent places have to know it:
// resolveModel's GADFLY_BASE_URL override, and endpointProvider's
// GADFLY_ENDPOINT_* parser. A name accepted by one and rejected by the other is
// a provider that works when configured one way and errors the other, for no
// reason a user could guess. Asserting both from one table makes the pair fail
// together.
func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {
// Ranges the SHARED slice: a test that pins a list against drift must not
// be able to drift from it.
for _, provider := range openAICompatProviders {
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
p, err := endpointProvider("ep", provider+"|https://host.example/v1|sk-x")
if err != nil {
t.Fatalf("endpointProvider(%q): %v", provider, err)
}
if p.Name() != "ep" {
t.Errorf("Name() = %q, want %q", p.Name(), "ep")
}
})
t.Run(provider+" via GADFLY_BASE_URL", func(t *testing.T) {
t.Setenv("GADFLY_PROVIDER", provider)
t.Setenv("GADFLY_BASE_URL", "https://host.example/v1")
t.Setenv("GADFLY_API_KEY", "sk-x")
t.Setenv("GADFLY_MODEL", "some-model")
if _, err := resolveModel(); err != nil {
t.Fatalf("resolveModel with GADFLY_PROVIDER=%q: %v", provider, err)
}
})
}
}
// TestEndpointProviderNamesAreAllAccepted keeps the operator-facing list
// honest: every name endpointProviderNames advertises must actually resolve.
// The constant is read by somebody whose config just failed, so a name listed
// there and rejected by the code sends them to debug a spelling that was never
// going to work.
func TestEndpointProviderNamesAreAllAccepted(t *testing.T) {
for _, name := range strings.Split(endpointProviderNames, "/") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
// Both switches, not one: this constant is the error text for BOTH
// GADFLY_ENDPOINT_* and GADFLY_BASE_URL, so a name accepted by only
// half of them still misleads whichever operator hits the other path.
t.Run(name+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
if _, err := endpointProvider("ep", name+"|https://host.example/v1|sk-x"); err != nil {
t.Errorf("endpointProviderNames advertises %q but endpointProvider rejects it: %v", name, err)
}
})
t.Run(name+" via GADFLY_BASE_URL", func(t *testing.T) {
t.Setenv("GADFLY_PROVIDER", name)
t.Setenv("GADFLY_BASE_URL", "https://host.example/v1")
t.Setenv("GADFLY_API_KEY", "sk-x")
t.Setenv("GADFLY_MODEL", "some-model")
if _, err := resolveModel(); err != nil {
t.Errorf("endpointProviderNames advertises %q but resolveModel rejects it: %v", name, err)
}
})
}
}
func TestBuildSpec(t *testing.T) {
tests := []struct {
name string
@@ -89,3 +164,220 @@ func TestBuildSpec(t *testing.T) {
})
}
}
// TestOpenAICompatProvidersAreFullyWired closes the two remaining ways this
// provider family can be half-added. Adding one means touching three places in
// two languages, and nothing but this test connects them:
//
// - endpointProviderNames is the operator-facing list. A provider the code
// accepts but the list omits sends someone debugging a name that works.
// - scripts/preflight.sh needs a credential arm, or a missing key for that
// provider skips the pre-flight and arrives as five unexplained per-lens
// failures — the exact thing the pre-flight exists to replace.
//
// The shell side is queried, not parsed: preflight.sh exports
// gadfly_preflight_providers precisely so this test asks it what it covers.
// Regexing the case statement would make that file's formatting a contract no
// linter enforces, and a harmless reformat would fail a test in another
// language.
func TestOpenAICompatProvidersAreFullyWired(t *testing.T) {
advertised := make(map[string]bool)
for _, n := range strings.Split(endpointProviderNames, "/") {
advertised[strings.TrimSpace(n)] = true
}
// Locate the script relative to THIS source file rather than the working
// directory, so moving the package does not silently break the lookup.
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed; cannot locate scripts/preflight.sh")
}
script := filepath.Join(filepath.Dir(thisFile), "..", "..", "scripts", "preflight.sh")
out, err := exec.Command("bash", "-c", ". "+script+"; gadfly_preflight_providers").Output()
if err != nil {
t.Fatalf("query gadfly_preflight_providers from %s: %v", script, err)
}
preflighted := make(map[string]bool)
for _, line := range strings.Fields(string(out)) {
preflighted[line] = true
}
if len(preflighted) == 0 {
t.Fatal("gadfly_preflight_providers returned nothing — this test would pass vacuously")
}
for _, p := range openAICompatProviders {
if !advertised[p] {
t.Errorf("openAICompatProviders has %q but endpointProviderNames does not list it — "+
"the error message operators read would omit a name that works", p)
}
if !preflighted[p] {
t.Errorf("openAICompatProviders has %q but scripts/preflight.sh has no credential arm for it — "+
"a missing key for %s would skip the pre-flight and surface as unexplained lens failures", p, p)
}
}
}
// TestBuiltinCompatProvidersResolveViaRegistry exercises the PRIMARY path:
// a plain "qwen/<model>" in GADFLY_MODELS, with no GADFLY_BASE_URL, resolved
// through majordomo's registry rather than constructed here.
//
// Every other test in this file builds the client directly, so all of them
// passed against a majordomo release that had never heard of qwen — the
// dependency bump this feature depends on was missing and nothing said so. A
// compile error eventually caught it, which is luck, not cover.
func TestBuiltinCompatProvidersResolveViaRegistry(t *testing.T) {
for _, spec := range []string{"qwen/qwen3.8-max", "kimi/kimi-k2-0711-preview"} {
t.Run(spec, func(t *testing.T) {
t.Setenv("GADFLY_MODEL", spec)
t.Setenv("GADFLY_BASE_URL", "")
t.Setenv("GADFLY_PROVIDER", "")
if _, err := resolveModel(); err != nil {
t.Fatalf("resolveModel(%q): %v — the pinned majordomo may not "+
"provide this built-in; a bump is required, not just gadfly-side wiring", spec, err)
}
})
}
}
// TestBuiltinCompatProvidersNeverInheritOpenAIKey pins the rule that has now
// been broken on one path or the other three separate times: kimi and qwen are
// other vendors, openai.New defaults its credential to OPENAI_API_KEY, and a
// provider built without an explicit key therefore puts an OpenAI key on the
// wire to Moonshot or Alibaba.
//
// Both construction paths are asserted from one loop deliberately. Each time
// this was fixed on a single path the sibling kept leaking, so a test covering
// one of them would have passed through every one of those bugs.
//
// The provider is pointed at a LOCAL server, and the test demands two things:
// that no request arrives carrying the foreign key, and that the call fails
// closed naming the variable to set. Without the second half the test would
// pass on a provider that simply did nothing.
func TestBuiltinCompatProvidersNeverInheritOpenAIKey(t *testing.T) {
const foreign = "sk-openai-must-not-travel"
for _, provider := range builtinCompatProviders {
t.Run(provider+" via GADFLY_BASE_URL", func(t *testing.T) {
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
t.Setenv("GADFLY_PROVIDER", provider)
t.Setenv("GADFLY_BASE_URL", srv.URL+"/v1")
t.Setenv("GADFLY_API_KEY", "") // the operator forgot the key
t.Setenv("GADFLY_MODEL", "some-model")
m, err := resolveModel()
if err != nil {
t.Fatalf("resolveModel: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
p, err := endpointProvider("ep", provider+"|"+srv.URL+"/v1") // no key field
if err != nil {
t.Fatalf("endpointProvider: %v", err)
}
m, err := p.Model("some-model")
if err != nil {
t.Fatalf("Model: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
}
}
// leakServer returns a server that records every Authorization header it is
// sent. A request arriving at all means the client did not fail closed.
func leakServer(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"c1","object":"chat.completion","choices":[{"index":0,` +
`"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
t.Cleanup(srv.Close)
return srv, &seen
}
func assertFailsClosed(t *testing.T, m llm.Model, seen *[]string, foreign string, wantAnyHint ...string) {
t.Helper()
_, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}})
for _, auth := range *seen {
if strings.Contains(auth, foreign) {
t.Errorf("Authorization carried the OpenAI key to another vendor: %q", auth)
}
}
if len(*seen) > 0 {
t.Errorf("a keyless provider reached the network (%d request(s)) instead of failing closed", len(*seen))
}
// The positive half: prove it refused for the right reason, so the test
// cannot pass on a provider that quietly did nothing at all.
if err == nil {
t.Fatal("keyless provider returned no error; expected a missing-key failure")
}
// The hint must name a MASKED secret the operator can set, never the
// unmasked GADFLY_ENDPOINT_* variable.
named := false
for _, h := range wantAnyHint {
if strings.Contains(err.Error(), h) {
named = true
}
}
if !named {
t.Errorf("error = %v, want it to name one of %v so the operator knows what to set", err, wantAnyHint)
}
}
// TestBuiltinCompatOwnKeyFallback: with no key in the endpoint definition, a
// built-in falls back to its OWN variable (QWEN_API_KEY, KIMI_API_KEY) — never
// to another vendor's. This is what lets the credential live in a masked
// secret while the endpoint URL lives in a GADFLY_ENDPOINT_* var, which Gitea
// does not mask; the README used to advise embedding the key in that var.
func TestBuiltinCompatOwnKeyFallback(t *testing.T) {
const own, foreign = "sk-qwen-own", "sk-openai-must-not-travel"
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
t.Setenv("QWEN_API_KEY", own)
p, err := endpointProvider("ep", "qwen|"+srv.URL+"/v1") // no key field
if err != nil {
t.Fatalf("endpointProvider: %v", err)
}
m, err := p.Model("some-model")
if err != nil {
t.Fatalf("Model: %v", err)
}
if _, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}}); err != nil {
t.Fatalf("Generate: %v", err)
}
if len(*seen) == 0 {
t.Fatal("no request reached the server — the own-key fallback did not take effect")
}
for _, auth := range *seen {
if strings.Contains(auth, foreign) {
t.Errorf("Authorization carried the OpenAI key: %q", auth)
}
if !strings.Contains(auth, own) {
t.Errorf("Authorization = %q, want the provider's own QWEN_API_KEY", auth)
}
}
}
// TestBuiltinCompatProvidersAreOpenAICompat: the two slices are parallel, and a
// built-in missing from openAICompatProviders would never reach the branch that
// applies its unconditional-key rule — it would fall through to the generic
// switch and silently lose the protection.
func TestBuiltinCompatProvidersAreOpenAICompat(t *testing.T) {
for _, p := range builtinCompatProviders {
if !isOpenAICompatProvider(p) {
t.Errorf("%q is in builtinCompatProviders but not openAICompatProviders, so the "+
"no-cross-vendor-fallback branch never runs for it", p)
}
}
}
+273
View File
@@ -0,0 +1,273 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in
// non-interactive `run` mode. Like the claude-code engine it is a pure shell-out
// — OpenCode brings its OWN read tools (read/grep/glob/list) and reads the
// checked-out tree, so findings are verified against real code — but it drives an
// ollama-cloud model instead of a Claude subscription. The point is to benchmark
// gadfly's boutique executus harness against a freely-available agentic harness
// on the SAME models (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").
//
// OpenCode has no --append-system-prompt flag, so the lens system prompt AND the
// read-only discipline are delivered through a generated config injected via the
// OPENCODE_CONFIG_CONTENT env var (see config): a custom "gadfly" agent whose
// prompt is the system prompt with edit/bash denied, plus a "gadfly" provider
// pointing at ollama-cloud. OPENCODE_CONFIG_CONTENT is the highest-precedence
// config source that matters in the container — it outranks any opencode.json a
// reviewed repo might ship — so a repo can't re-enable edits on us.
type openCodeEngine struct {
bin string // CLI binary (GADFLY_OPENCODE_BIN, default "opencode")
providerModel string // ollama model id for the generated "gadfly" provider ("" = none)
modelRef string // --model value ("gadfly/<id>", a pass-through "<prov>/<id>", or "" = CLI default)
baseURL string // ollama-cloud base URL (GADFLY_OPENCODE_BASE_URL)
repoDir string // cwd for the CLI, so its tools read the checked-out tree
extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS)
}
// openCodeProviderName / openCodeAgentName are the internal names of the provider
// and agent gadfly generates in the injected config. "gadfly" won't collide with
// OpenCode's models.dev provider registry.
const (
openCodeProviderName = "gadfly"
openCodeAgentName = "gadfly"
)
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
const defaultOpenCodeBaseURL = "https://ollama.com/v1"
// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine:
// the bare id "opencode"/"open-code" or an "opencode/<model>" form (both
// spellings accepted; "opencode" is canonical).
func isOpenCodeSpec(model string) bool {
m := strings.TrimSpace(model)
for _, p := range []string{"opencode", "open-code"} {
if m == p || strings.HasPrefix(m, p+"/") {
return true
}
}
return false
}
// newOpenCodeEngine builds the engine from the GADFLY_MODEL spec and the optional
// GADFLY_OPENCODE_* overrides. The part after the FIRST slash is the model, taken
// verbatim — no ":"-suffix parsing, because ollama model ids legitimately contain
// colons (e.g. "qwen3-coder:480b-cloud"). Three spec forms:
//
// opencode → bare: no --model, no generated provider (CLI default model)
// opencode/<model> → wrap <model> in the generated ollama-cloud "gadfly" provider
// opencode/<provider>/<model> → pass-through: --model <provider>/<model>, using OpenCode's
// own provider registry/auth (escape hatch, no generated provider)
//
// GADFLY_OPENCODE_MODEL overrides the model taken from the spec (and is itself run
// through the same slash logic). It does not verify the CLI is installed — a
// missing binary surfaces as a normal pass error (advisory, never fatal).
func newOpenCodeEngine(spec, repoDir string) *openCodeEngine {
e := &openCodeEngine{
bin: envOr("GADFLY_OPENCODE_BIN", "opencode"),
baseURL: envOr("GADFLY_OPENCODE_BASE_URL", defaultOpenCodeBaseURL),
repoDir: repoDir,
extraArgs: strings.Fields(os.Getenv("GADFLY_OPENCODE_EXTRA_ARGS")),
}
var after string
if _, a, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
after = strings.TrimSpace(a)
}
if env := strings.TrimSpace(os.Getenv("GADFLY_OPENCODE_MODEL")); env != "" {
after = env
}
switch {
case after == "":
// bare spec: let OpenCode's configured default model apply.
case strings.Contains(after, "/"):
// "<provider>/<model>" pass-through to an OpenCode built-in provider.
e.modelRef = after
default:
// A bare model id → serve it via the generated ollama-cloud provider.
e.providerModel = after
e.modelRef = openCodeProviderName + "/" + after
}
return e
}
// args assembles the `opencode` argv for one pass. Factored out (and pure) so it
// can be unit-tested without invoking the CLI. The task is the positional message
// and MUST come last; it never begins with '-' (buildTask output starts with "PR
// title:"/"Review …"), so no "--" terminator is needed. Note: in `opencode run`,
// -p is basic-auth password, NOT the prompt — the message is positional.
func (e *openCodeEngine) args(task string) []string {
a := []string{"run", "--agent", openCodeAgentName}
if e.modelRef != "" {
a = append(a, "--model", e.modelRef)
}
a = append(a, e.extraArgs...)
return append(a, task)
}
// openCode config structs — the minimal shape gadfly generates. Marshaled to JSON
// and handed to the CLI via OPENCODE_CONFIG_CONTENT.
type openCodeConfig struct {
Schema string `json:"$schema"`
Permission openCodePermission `json:"permission"`
Provider map[string]openCodeProvider `json:"provider,omitempty"`
Agent map[string]openCodeAgent `json:"agent"`
}
// openCodePermission is OpenCode's permission map: tool key → "allow"|"ask"|"deny".
type openCodePermission map[string]string
// denyMutations denies every OpenCode permission that could change the repo, run
// commands, or reach the network / the filesystem outside the checked-out tree —
// keeping the reviewer strictly read-only. The read/search tools OpenCode gates
// separately (read/glob/grep/list/lsp) stay at their default so the agent can
// still verify findings against the code. OpenCode's permission keys are
// enumerated at https://opencode.ai/docs/agents; a key it doesn't recognize is
// simply ignored, so listing extras is safe.
func denyMutations() openCodePermission {
return openCodePermission{
"edit": "deny",
"bash": "deny",
"webfetch": "deny",
"websearch": "deny",
"external_directory": "deny",
}
}
type openCodeProvider struct {
NPM string `json:"npm"`
Name string `json:"name"`
Options openCodeProviderOptions `json:"options"`
Models map[string]struct{} `json:"models"`
}
type openCodeProviderOptions struct {
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
}
type openCodeAgent struct {
Description string `json:"description"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
Permission openCodePermission `json:"permission"`
}
// config builds the OpenCode config JSON for one pass. The system prompt becomes
// the "gadfly" agent's prompt; the mutating/network tools are denied at BOTH the
// global and agent level (defense in depth — OpenCode's read tools stay
// available). For a bare or pass-through spec the generated ollama-cloud provider
// block is omitted. The API key is always the "{env:OLLAMA_API_KEY}" reference,
// never a literal secret baked into the config.
func (e *openCodeEngine) config(system string) ([]byte, error) {
deny := denyMutations()
cfg := openCodeConfig{
Schema: "https://opencode.ai/config.json",
Permission: deny,
Agent: map[string]openCodeAgent{
openCodeAgentName: {
Description: "Gadfly adversarial code-review lens (read-only).",
Mode: "primary",
Prompt: system,
Permission: deny,
},
},
}
if e.providerModel != "" {
cfg.Provider = map[string]openCodeProvider{
openCodeProviderName: {
NPM: "@ai-sdk/openai-compatible",
Name: openCodeProviderName,
Options: openCodeProviderOptions{
BaseURL: e.baseURL,
APIKey: "{env:OLLAMA_API_KEY}",
},
Models: map[string]struct{}{e.providerModel: {}},
},
}
}
return json.Marshal(cfg)
}
func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
cfg, err := e.config(system)
if err != nil {
return "", fmt.Errorf("opencode config: %w", err)
}
cmd := exec.CommandContext(ctx, e.bin, e.args(task)...)
cmd.Dir = e.repoDir
// Inject the review config (system prompt as the agent prompt + read-only
// permissions + the ollama-cloud provider) via OPENCODE_CONFIG_CONTENT, which
// outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the
// captured stdout free of ANSI decoration.
cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1")
killGroupOnCancel(cmd) // don't orphan the CLI's Node children on a timed-out lens
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
// A cancelled/timed-out run must surface as an error, never as whatever partial
// bytes the CLI flushed before it was killed.
if ctx.Err() != nil {
return "", fmt.Errorf("opencode run %v", ctx.Err())
}
if runErr != nil {
detail := truncateForErr(stderr.String())
if detail == "" {
detail = truncateForErr(stdout.String())
}
if detail != "" {
return "", fmt.Errorf("opencode run failed: %v: %s", runErr, detail)
}
return "", fmt.Errorf("opencode run failed: %v", runErr)
}
// OpenCode's default (non-JSON) format prints the assistant's final text; trust
// it as the review. An empty result on a clean exit is an error, never "".
if out := strings.TrimSpace(stdout.String()); out != "" {
return out, nil
}
return "", fmt.Errorf("opencode run returned no output")
}
// openCodeEnv builds a minimal environment for the `opencode` subprocess. It
// forwards what the CLI needs to reach a model provider: OLLAMA_API_KEY for the
// generated ollama-cloud provider (the primary opencode/<model> path), PLUS the
// standard provider keys — ANTHROPIC_*, OPENAI_*, GOOGLE_*, GEMINI_* — so the
// opencode/<provider>/<model> pass-through form can authenticate against
// OpenCode's own built-in providers (those keys are otherwise stripped, which
// broke the documented escape hatch). It still withholds gadfly's OWN secrets —
// GITEA_TOKEN, GADFLY_API_KEY, GADFLY_FINDINGS_TOKEN, and the claude-code
// subscription token (CLAUDE_CODE_OAUTH_TOKEN, which OpenCode can't use anyway) —
// so they never reach the third-party CLI. OPENCODE_CONFIG_CONTENT is never
// inherited: runPass sets it, and a duplicate key would be ambiguous (getenv
// returns the first occurrence).
func openCodeEnv() []string {
return filterEnv(func(k string) bool {
if k == "OPENCODE_CONFIG_CONTENT" {
return false // set explicitly by runPass; never inherit a competing value
}
switch k {
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "OLLAMA_API_KEY":
return true
}
return strings.HasPrefix(k, "LC_") ||
strings.HasPrefix(k, "OPENCODE_") ||
strings.HasPrefix(k, "GADFLY_OPENCODE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_") ||
strings.HasPrefix(k, "ANTHROPIC_") ||
strings.HasPrefix(k, "OPENAI_") ||
strings.HasPrefix(k, "GOOGLE_") ||
strings.HasPrefix(k, "GEMINI_")
})
}
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"context"
"encoding/json"
"os"
"slices"
"strings"
"testing"
)
func TestIsOpenCodeSpec(t *testing.T) {
cases := map[string]bool{
"opencode": true,
"opencode/glm-5.2": true,
"open-code/glm-5.2": true, // accepted alias spelling
"opencode/qwen3-coder:480b-cloud": true, // colon-bearing model id
"opencode/anthropic/claude": true, // pass-through form
" opencode ": true, // trimmed
"opencode-extra": false, // not the bare id, not a "/" form
"qwen3-coder:480b-cloud": false,
"claude-code/opus": false,
"": false,
}
for spec, want := range cases {
if got := isOpenCodeSpec(spec); got != want {
t.Errorf("isOpenCodeSpec(%q) = %v, want %v", spec, got, want)
}
}
}
func TestNewOpenCodeEngineModel(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
// "opencode/<model>" → wrapped in the generated "gadfly" provider.
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "glm-5.2" || e.modelRef != "gadfly/glm-5.2" {
t.Errorf("glm-5.2: providerModel=%q modelRef=%q, want glm-5.2 / gadfly/glm-5.2", e.providerModel, e.modelRef)
}
// Colon-bearing ollama id is preserved verbatim — NOT split on ":".
if e := newOpenCodeEngine("opencode/qwen3-coder:480b-cloud", "/repo"); e.providerModel != "qwen3-coder:480b-cloud" {
t.Errorf("colon id: providerModel=%q, want qwen3-coder:480b-cloud (no split)", e.providerModel)
}
// "open-code/" spelling behaves identically.
if e := newOpenCodeEngine("open-code/glm-5.2", "/repo"); e.modelRef != "gadfly/glm-5.2" {
t.Errorf("open-code alias: modelRef=%q, want gadfly/glm-5.2", e.modelRef)
}
// Pass-through "opencode/<provider>/<model>" → no generated provider.
if e := newOpenCodeEngine("opencode/anthropic/claude-sonnet-4-6", "/repo"); e.providerModel != "" || e.modelRef != "anthropic/claude-sonnet-4-6" {
t.Errorf("pass-through: providerModel=%q modelRef=%q, want '' / anthropic/claude-sonnet-4-6", e.providerModel, e.modelRef)
}
// Bare spec → no model, no provider (CLI default applies).
if e := newOpenCodeEngine("opencode", "/repo"); e.providerModel != "" || e.modelRef != "" {
t.Errorf("bare: providerModel=%q modelRef=%q, want both empty", e.providerModel, e.modelRef)
}
// GADFLY_OPENCODE_MODEL overrides the spec suffix.
t.Setenv("GADFLY_OPENCODE_MODEL", "deepseek-v3")
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "deepseek-v3" || e.modelRef != "gadfly/deepseek-v3" {
t.Errorf("env override: providerModel=%q modelRef=%q, want deepseek-v3 / gadfly/deepseek-v3", e.providerModel, e.modelRef)
}
}
func TestOpenCodeEngineDefaults(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_BIN", "")
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
if e.bin != "opencode" {
t.Errorf("bin = %q, want opencode", e.bin)
}
if e.baseURL != defaultOpenCodeBaseURL {
t.Errorf("baseURL = %q, want %q", e.baseURL, defaultOpenCodeBaseURL)
}
if e.repoDir != "/repo" {
t.Errorf("repoDir = %q, want /repo", e.repoDir)
}
}
func TestOpenCodeArgs(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "--variant reasoning")
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
args := e.args("TASK-PROMPT")
// "run" is the subcommand and must be first.
if len(args) == 0 || args[0] != "run" {
t.Fatalf("args[0] = %q, want run (args=%v)", args, args)
}
if argAfter(args, "--agent") != openCodeAgentName {
t.Errorf("--agent = %q, want %q", argAfter(args, "--agent"), openCodeAgentName)
}
if argAfter(args, "--model") != "gadfly/glm-5.2" {
t.Errorf("--model = %q, want gadfly/glm-5.2", argAfter(args, "--model"))
}
// extra args appended verbatim (split on whitespace).
if !strings.Contains(strings.Join(args, " "), "--variant reasoning") {
t.Errorf("extra args not appended: %v", args)
}
// task is the positional message and must be LAST.
if args[len(args)-1] != "TASK-PROMPT" {
t.Errorf("last arg = %q, want TASK-PROMPT (args=%v)", args[len(args)-1], args)
}
}
func TestOpenCodeArgsBareModelOmitsFlag(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
e := newOpenCodeEngine("opencode", "/repo")
args := e.args("t")
if slices.Contains(args, "--model") {
t.Errorf("--model should be omitted for a bare opencode spec: %v", args)
}
if args[len(args)-1] != "t" {
t.Errorf("last arg = %q, want t", args[len(args)-1])
}
}
func TestOpenCodeConfig(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
// Round-trip a system prompt containing quotes and newlines.
sys := "Line one with \"quotes\".\nLine two."
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
raw, err := e.config(sys)
if err != nil {
t.Fatalf("config: %v", err)
}
var cfg openCodeConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("generated config is not valid JSON: %v\n%s", err, raw)
}
// Agent carries the system prompt verbatim and denies edit+bash.
ag, ok := cfg.Agent[openCodeAgentName]
if !ok {
t.Fatalf("agent %q missing from config", openCodeAgentName)
}
if ag.Prompt != sys {
t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt)
}
// Mutating/network tools are denied at BOTH the agent and global level (defense
// in depth); the read/search tools stay at OpenCode's default.
for _, k := range []string{"edit", "bash", "webfetch", "websearch", "external_directory"} {
if ag.Permission[k] != "deny" {
t.Errorf("agent permission[%q] = %q, want deny", k, ag.Permission[k])
}
if cfg.Permission[k] != "deny" {
t.Errorf("global permission[%q] = %q, want deny", k, cfg.Permission[k])
}
}
// Provider block: correct npm, default baseURL, env-ref apiKey, model in map.
prov, ok := cfg.Provider[openCodeProviderName]
if !ok {
t.Fatalf("provider %q missing from config", openCodeProviderName)
}
if prov.NPM != "@ai-sdk/openai-compatible" {
t.Errorf("provider npm = %q, want @ai-sdk/openai-compatible", prov.NPM)
}
if prov.Options.BaseURL != defaultOpenCodeBaseURL {
t.Errorf("provider baseURL = %q, want %q", prov.Options.BaseURL, defaultOpenCodeBaseURL)
}
if prov.Options.APIKey != "{env:OLLAMA_API_KEY}" {
t.Errorf("provider apiKey = %q, want {env:OLLAMA_API_KEY} (never a literal secret)", prov.Options.APIKey)
}
if _, ok := prov.Models["glm-5.2"]; !ok {
t.Errorf("provider models = %v, want it to contain glm-5.2", prov.Models)
}
// GADFLY_OPENCODE_BASE_URL override reaches the provider.
t.Setenv("GADFLY_OPENCODE_BASE_URL", "http://localhost:11434/v1")
e2 := newOpenCodeEngine("opencode/glm-5.2", "/repo")
raw2, err := e2.config(sys)
if err != nil {
t.Fatalf("config override: %v", err)
}
var cfg2 openCodeConfig
if err := json.Unmarshal(raw2, &cfg2); err != nil {
t.Fatalf("override config invalid JSON: %v", err)
}
if got := cfg2.Provider[openCodeProviderName].Options.BaseURL; got != "http://localhost:11434/v1" {
t.Errorf("override baseURL = %q, want http://localhost:11434/v1", got)
}
}
func TestOpenCodeConfigNoProviderForPassThroughAndBare(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
for _, spec := range []string{"opencode", "opencode/anthropic/claude-sonnet-4-6"} {
e := newOpenCodeEngine(spec, "/repo")
raw, err := e.config("sys")
if err != nil {
t.Fatalf("config(%q): %v", spec, err)
}
if strings.Contains(string(raw), "\"provider\"") {
t.Errorf("spec %q: config should omit the provider block, got %s", spec, raw)
}
}
}
func TestOpenCodeEnvFilters(t *testing.T) {
t.Setenv("GITEA_TOKEN", "secret-gitea")
t.Setenv("OLLAMA_API_KEY", "keep-ollama")
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic") // pass-through provider auth
t.Setenv("OPENAI_API_KEY", "keep-openai") // pass-through provider auth
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude")
t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob")
t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit")
env := openCodeEnv()
has := func(k string) bool {
for _, kv := range env {
if strings.HasPrefix(kv, k+"=") {
return true
}
}
return false
}
// kept: the ollama key + the standard provider keys the opencode/<provider>/<model>
// pass-through form needs + opencode knobs + PATH
for _, k := range []string{"OLLAMA_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
if !has(k) {
t.Errorf("openCodeEnv dropped %s, but it should be kept", k)
}
}
// dropped: gadfly's own secrets + the claude engine's subscription token
// (OpenCode's anthropic provider uses ANTHROPIC_API_KEY, not this OAuth token).
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} {
if has(k) {
t.Errorf("openCodeEnv leaked %s into the subprocess env", k)
}
}
// OPENCODE_CONFIG_CONTENT must NOT be inherited — runPass sets it, and a
// duplicate key would be ambiguous.
if has("OPENCODE_CONFIG_CONTENT") {
t.Errorf("openCodeEnv inherited OPENCODE_CONFIG_CONTENT; runPass sets it explicitly")
}
}
// stubOpenCode writes an executable shell stub that prints body and exits code,
// and returns an engine pointed at it.
func stubOpenCode(t *testing.T, body string, code int) *openCodeEngine {
t.Helper()
dir := t.TempDir()
path := dir + "/opencode-stub.sh"
script := "#!/bin/sh\nprintf '%s' " + shSingleQuote(body) + "\nexit " + itoa(code) + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return &openCodeEngine{bin: path, repoDir: dir}
}
func TestOpenCodeRunPassCleanResult(t *testing.T) {
e := stubOpenCode(t, " REVIEW TEXT ", 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err != nil || out != "REVIEW TEXT" {
t.Fatalf("clean result: got (%q, %v), want (REVIEW TEXT, nil)", out, err)
}
}
func TestOpenCodeRunPassEmptyIsError(t *testing.T) {
e := stubOpenCode(t, " ", 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil {
t.Fatalf("empty output should be an error, got out=%q", out)
}
}
func TestOpenCodeRunPassNonZero(t *testing.T) {
e := stubOpenCode(t, "fatal: provider auth failed", 1)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "opencode run failed") {
t.Fatalf("non-zero exit should error with detail, got %v", err)
}
}
// TestOpenCodeRunPassInjectsConfig proves the end-to-end env plumbing: the stub
// echoes OPENCODE_CONFIG_CONTENT back, and the emitted JSON must carry the exact
// system prompt as the gadfly agent's prompt.
func TestOpenCodeRunPassInjectsConfig(t *testing.T) {
dir := t.TempDir()
stub := dir + "/opencode-stub.sh"
script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\"\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
e := newOpenCodeEngine("opencode/glm-5.2", dir)
e.bin = stub
sys := "SYSTEM-PROMPT-SENTINEL\nwith a second line"
out, err := e.runPass(context.Background(), sys, "task", 0)
if err != nil {
t.Fatalf("runPass: %v", err)
}
var cfg openCodeConfig
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
t.Fatalf("injected config is not valid JSON: %v\n%s", err, out)
}
if got := cfg.Agent[openCodeAgentName].Prompt; got != sys {
t.Errorf("injected agent prompt = %q, want the system prompt", got)
}
}
+57 -29
View File
@@ -33,12 +33,24 @@
# Optional config:
# GADFLY_MODELS comma-separated model ids/specs (alias: OLLAMA_REVIEW_MODELS)
# GADFLY_PROVIDER majordomo provider for bare model ids (default ollama-cloud;
# e.g. "ollama" local, "openai", "anthropic", "google")
# e.g. "ollama" local, "openai", "anthropic", "google",
# "qwen" Alibaba Model Studio, "kimi" Moonshot)
# GADFLY_BASE_URL override backend endpoint (OpenAI/Ollama-compatible servers)
# GADFLY_API_KEY provider key (else provider's standard env: OPENAI_API_KEY, …)
# QWEN_API_KEY Alibaba Model Studio key, for GADFLY_MODELS entries like
# "qwen/qwen3.8-max". Read ONLY by the qwen provider — it
# does not fall back to OPENAI_API_KEY, so a forgotten key
# is a clean skip notice naming this variable, not a 401.
# KIMI_API_KEY Moonshot key, same deal for "kimi/<model>". Distinct from
# the ollama-cloud "kimi-k2.6:cloud" entry, which is keyed
# by OLLAMA_CLOUD_API_KEY.
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
# "claude-code"/"claude-code/<model>"); Pro/Max subscription
# token from `claude setup-token`. Else ANTHROPIC_API_KEY.
# OLLAMA_CLOUD_API_KEY also feeds the opencode engine (GADFLY_MODELS entry
# "opencode/<model>"): the bundled `opencode` CLI drives
# that ollama-cloud model, for benchmarking the two
# harnesses on the same model. Tune via GADFLY_OPENCODE_*.
# GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review")
# GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger;
# empty => fall back to "is a repo collaborator"
@@ -194,14 +206,18 @@ export GADFLY_FINDINGS_TOKEN="${GADFLY_FINDINGS_TOKEN:-}"
# provider key envs (OPENAI_API_KEY, …) are inherited by run.sh and the binary.
#
# Concurrency: each PROVIDER is its own lane and lanes run in PARALLEL, so a fast
# cloud provider isn't stuck behind a slow local box. Within a lane, at most
# `cap` models run at once. cap = GADFLY_PROVIDER_CONCURRENCY's "provider=N"
# entry, else GADFLY_CONCURRENCY (default 1). A model's provider is the spec's
# first path segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER /
# ollama-cloud for a bare id. Default (cap 1) keeps a single-provider pool fully
# sequential, exactly as before.
# cloud provider isn't stuck behind a slow local box. Within a lane ALL of the
# provider's models run at once; the real throttle is a single PROVIDER-WIDE lens
# budget (a shared permit pool, seeded per lane) that every model's lenses draw
# from — so total lens passes in flight per provider is bounded, but a model
# winding down to its last lens immediately yields its freed permits to another
# model's queued lenses instead of holding a whole "model slot" (the old
# GADFLY_PROVIDER_CONCURRENCY model cap, now removed, caused that tail stall). The
# budget = GADFLY_PROVIDER_LENS_CONCURRENCY's "provider=N" entry, else
# GADFLY_LENS_CONCURRENCY (default 1). A model's provider is the spec's first path
# segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER / ollama-cloud
# for a bare id.
MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"
DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"
# --- huge-PR downshift ------------------------------------------------------
# A very large diff is what burns the model budget: every review step re-sends
@@ -241,24 +257,33 @@ provider_of() { case "$1" in */*) echo "${1%%/*}";; *) echo "${GADFLY_PROVIDER:-
STATUS_DIR="${WORKDIR}/status"
status_file_for() { echo "${STATUS_DIR}/$(echo "$1" | tr -c '[:alnum:]._-' '_').json"; }
provider_cap() { # provider -> concurrency (override map "p=N,...", else default)
# Root of the per-provider lens permit pools (one subdir per lane, seeded by
# run_lane). Cleared up front so a reused WORKDIR can't leak stale permit files.
LENS_SEM_ROOT="${WORKDIR}/lenssem"
rm -rf "$LENS_SEM_ROOT" 2>/dev/null || true
provider_lens_cap() { # provider -> provider-wide lens budget (permit-pool size)
local p="$1" item k v
IFS=',' read -ra _caps <<< "${GADFLY_PROVIDER_CONCURRENCY:-}"
for item in "${_caps[@]}"; do
IFS=',' read -ra _lcaps <<< "${GADFLY_PROVIDER_LENS_CONCURRENCY:-}"
for item in "${_lcaps[@]}"; do
k="$(echo "${item%%=*}" | tr -d '[:space:]')"
v="$(echo "${item#*=}" | tr -d '[:space:]')"
if [ "$k" = "$p" ] && [ -n "$v" ]; then echo "$v"; return; fi
done
echo "$DEFAULT_CONC"
echo "${GADFLY_LENS_CONCURRENCY:-1}"
}
review_one() {
local sf="" ff=""
[ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$1")"
[ "$CONSOLIDATE" = "1" ] && ff="$(findings_file_for "$1")"
PROVIDER=ollama MODEL="$1" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \
local m="$1" sem_dir="${2:-}" sem_size="${3:-}" sf="" ff=""
[ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$m")"
[ "$CONSOLIDATE" = "1" ] && ff="$(findings_file_for "$m")"
# GADFLY_LENS_SEM_DIR/_SIZE point the binary at this provider's shared lens
# permit pool (empty => the binary just uses its in-process lens limit). These
# are inherited by the binary through run.sh's environment.
PROVIDER=ollama MODEL="$m" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \
GADFLY_STATUS_FILE="$sf" GADFLY_FINDINGS_OUT="$ff" GADFLY_CONSOLIDATE="$CONSOLIDATE" \
bash "${SCRIPTS_DIR}/run.sh" || log "model $1 failed (continuing)"
GADFLY_LENS_SEM_DIR="$sem_dir" GADFLY_LENS_SEM_SIZE="$sem_size" \
bash "${SCRIPTS_DIR}/run.sh" || log "model $m failed (continuing)"
# If the binary never wrote real status (run.sh skipped it: empty diff, no key,
# binary missing), the pre-seed stays {started:0, done:false} and the board
# would show this model "waiting to start" forever and never reach N/N. Mark
@@ -309,16 +334,19 @@ for m in "${MODEL_LIST[@]}"; do
case " $PROVIDERS " in *" $p "*) ;; *) PROVIDERS="${PROVIDERS}${PROVIDERS:+ }$p" ;; esac
done
run_lane() { # $1=provider: run its models, at most `cap` at a time
local p="$1" cap inflight=0 m
cap="$(provider_cap "$p")"; [ "$cap" -ge 1 ] 2>/dev/null || cap=1
run_lane() { # $1=provider: run ALL its models at once, throttled only by a shared
# provider-wide lens permit pool (no per-model cap).
local p="$1" budget sem_dir m
budget="$(provider_lens_cap "$p")"; [ "$budget" -ge 1 ] 2>/dev/null || budget=1
local mine=()
for m in "${MODEL_LIST[@]}"; do [ "$(provider_of "$m")" = "$p" ] && mine+=("$m"); done
log "lane ${p}: cap ${cap}; models: ${mine[*]}"
# Seed this provider's lens permit pool: a directory the binary flocks N permit
# files in (created lazily), one shared budget across every model in the lane.
sem_dir="${LENS_SEM_ROOT}/$(echo "$p" | tr -c '[:alnum:]._-' '_')"
mkdir -p "$sem_dir"
log "lane ${p}: lens budget ${budget} shared across ${#mine[@]} model(s): ${mine[*]}"
for m in "${mine[@]}"; do
review_one "$m" &
inflight=$((inflight+1))
if [ "$inflight" -ge "$cap" ]; then wait -n 2>/dev/null || wait; inflight=$((inflight-1)); fi
review_one "$m" "$sem_dir" "$budget" &
done
wait
}
@@ -334,8 +362,8 @@ BOARD_PID=""
if [ "${GADFLY_STATUS_BOARD:-1}" != "0" ]; then
rm -rf "$STATUS_DIR"; mkdir -p "$STATUS_DIR"
# Pre-seed every model as queued so the board shows the full swarm from t=0,
# even models still waiting on their provider lane's concurrency cap. Each
# binary overwrites its own file with real per-lens detail once it starts.
# even models whose lenses are still waiting on their provider's lens budget.
# Each binary overwrites its own file with real per-lens detail once it starts.
for m in "${MODEL_LIST[@]}"; do
jq -n --arg model "$m" --arg provider "$(provider_of "$m")" \
'{model:$model, provider:$provider, started:0, updated:0, done:false, lenses:[]}' \
@@ -374,9 +402,9 @@ if [ "${GADFLY_PR_BUDGET_SECS:-0}" -gt 0 ] 2>/dev/null; then
fi
log "providers: ${PROVIDERS:-none}"
# Each provider lane runs in parallel; cap is enforced within each lane. Track
# the lane PIDs so we wait ONLY for the review work — not the status board,
# which intentionally runs until we signal it below.
# Each provider lane runs in parallel; the shared lens budget throttles within
# each lane. Track the lane PIDs so we wait ONLY for the review work — not the
# status board, which intentionally runs until we signal it below.
LANE_PIDS=()
for p in $PROVIDERS; do
run_lane "$p" &
+1
View File
@@ -12,6 +12,7 @@ set the secrets/vars it references. Gadfly is advisory only — it never blocks
| [`openai-compatible.yml`](openai-compatible.yml) | any **OpenAI-compatible** endpoint (local Ollama `/v1`, gateway, vLLM, OpenRouter…) | `GADFLY_BASE_URL` (+ a key for most gateways) |
| [`endpoint-aliases.yml`](endpoint-aliases.yml) | **several named backends** at once (one comment each) | repo vars `GADFLY_ENDPOINT_<NAME>` |
| [`claude-code.yml`](claude-code.yml) | the bundled **Claude Code CLI** engine (`claude-code/<model>`) | secret `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) |
| [`opencode.yml`](opencode.yml) | the bundled **OpenCode CLI** engine (`opencode/<model>`) driving an ollama-cloud model — benchmark it against the majordomo loop on the same model | secret `OLLAMA_CLOUD_API_KEY` |
| [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — |
Common to all:
+7 -7
View File
@@ -55,14 +55,14 @@ jobs:
# csv to choose; "all" for everything; or define custom ones via a repo
# .gadfly.yml / GADFLY_SPECIALIST_<NAME>. See README "Specialists".
GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS }}
# Lens fan-out (optional; default 1 = lenses run sequentially within a
# model). Raise it to run a model's lenses concurrently so each model
# posts its comment sooner. Total in-flight requests = (models at once)
# × (lenses at once), so to fan out without oversubscribing a backend,
# keep its model cap low and raise its lens cap. Per-provider configurable
# via GADFLY_PROVIDER_LENS_CONCURRENCY (same lanes as the model map):
# GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
# Concurrency (optional; default 1 = fully sequential per provider). The
# ONE throttle is a per-provider LENS BUDGET: the max lens passes (a lens =
# one specialist's review+recheck) in flight at once for a provider, shared
# across ALL that provider's models — every model in a lane runs at once and
# its lenses draw from the shared budget. Raise it to overlap lenses; set it
# per provider with GADFLY_PROVIDER_LENS_CONCURRENCY:
# GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
# (The old GADFLY_PROVIDER_CONCURRENCY model cap was removed and is ignored.)
# GADFLY_LENS_CONCURRENCY: ${{ vars.GADFLY_LENS_CONCURRENCY }}
# GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ vars.GADFLY_PROVIDER_LENS_CONCURRENCY }}
# Live status board (optional; ON by default): one consolidated comment
+76
View File
@@ -0,0 +1,76 @@
# Gadfly reviewing via the OpenCode CLI engine.
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
#
# Instead of gadfly's own majordomo loop, each lens shells out to the bundled
# `opencode` CLI (opencode.ai) inside the checked-out repo — it uses its own read
# tools to verify findings — while driving an ollama-cloud model. Gadfly then runs
# its usual verdict + recheck + consolidate pipeline.
#
# Why: benchmark gadfly's boutique harness against a freely-available one ON THE
# SAME MODEL. List both entries to get one comment section each and compare:
# GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2"
#
# Auth: reuses the OLLAMA_CLOUD_API_KEY secret (same as the ollama-cloud path) —
# no OpenCode-specific credential is needed for the ollama-cloud provider.
#
# Heads-up: this engine is newly wired and lightly tested — read the README's
# "OpenCode engine" note before relying on it.
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment. Replace the username(s) below with your maintainers — keep them in
# sync with GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# --- OpenCode engine ---
# Reuses the ollama-cloud key; mapped to OLLAMA_API_KEY in-container and
# referenced by the generated provider as {env:OLLAMA_API_KEY}.
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
# "opencode/<model>" serves that model via ollama-cloud through OpenCode.
# Model ids are verbatim (colons preserved). List an "ollama-cloud/<model>"
# entry too to benchmark the two harnesses on the same model.
GADFLY_MODELS: "opencode/glm-5.2"
# Optional CLI tuning:
# GADFLY_OPENCODE_BASE_URL: "https://ollama.com/v1" # or a local Ollama /v1
# GADFLY_OPENCODE_MODEL: "glm-5.2" # overrides the spec suffix
# GADFLY_OPENCODE_EXTRA_ARGS: "--variant reasoning" # whitespace-split
# Escape hatch: "opencode/<provider>/<model>" passes straight to OpenCode's
# own provider registry/auth (e.g. opencode/anthropic/claude-sonnet-4-6).
GADFLY_ALLOWED_USERS: "your-username"
# --- event context (leave as-is) ---
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
+3 -2
View File
@@ -17,8 +17,9 @@
# review never touches), so prefer the explicit form. Pin to an immutable
# @<sha>: long-lived act_runners CACHE the reusable by ref, so a moved tag (@v1)
# or @main is often not re-fetched and silently runs a stale copy. Bump the @<sha>
# to adopt a structural change; routine swarm tuning rides owner variables (see
# the gadfly README "Central config via variables") with no re-pin needed.
# only to adopt a structural change; routine swarm tuning AND reviewer image
# releases ride owner variables (GADFLY_DEFAULT_*, GADFLY_REVIEWER_TAG — see the
# gadfly README "Central config via variables") with no re-pin needed.
#
# For custom named endpoints (GADFLY_ENDPOINT_<NAME>) or a provider the reusable
# doesn't map, use the full stub in adversarial-review.yml instead.
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.2
require (
gitea.stevedudenhoeffer.com/steve/executus v0.1.4
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9
gopkg.in/yaml.v3 v3.0.1
)
+2 -2
View File
@@ -6,8 +6,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
gitea.stevedudenhoeffer.com/steve/executus v0.1.4 h1:4F99uCV3OVaE9ITFp0FjPiYxLUQO+WpE+wU2HCnpXNM=
gitea.stevedudenhoeffer.com/steve/executus v0.1.4/go.mod h1:WQP/lH+meU06OSNF0TQO/wQLcJCrMwpi0EMj5vSpVtk=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462 h1:1crjE1YkWHLZ91tUDOxN/Y5cuOnJ56e0U9UADoFfEPY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9 h1:ExY2S6RN1UaA97ju4jzkuEGpfBx0p3vv9FY8B7Npy2I=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Credential pre-flight for the agentic reviewer, in ONE definition.
#
# Sourced by run.sh (production) and by preflight_test.sh (the table test), so
# the tested bytes and the running bytes are the same. Keep it that way: a test
# that reimplements this logic can agree with a stale copy of it.
#
# Why pre-flight at all, when majordomo already fails closed with a 401:
# without it a missing key surfaces as five identical per-lens agent failures
# that name no variable, and the operator reads a stack trace to learn which
# secret they forgot to forward.
# gadfly_preflight_key <provider> -> echoes "" when the run may proceed, or the
# name of the environment variable the operator must set.
#
# Scope: the REGISTRY path only — GADFLY_BASE_URL unset — and deliberately so.
# The two resolution paths have DIFFERENT credential rules: with an explicit
# endpoint the credential is GADFLY_API_KEY (falling back to the client's own
# default, OPENAI_API_KEY for the openai family) and a built-in's own variable
# is never consulted; without one, the reverse. Applying either path's rule to
# the other yields a check that passes a run which then 401s — the precise
# failure this exists to prevent. So it covers the path whose rules it can state
# exactly and stays silent on the other. That is also the useful half: an
# override-path config is hand-written, while the registry path is what somebody
# hits by adding a model id to a var and forgetting the secret.
gadfly_preflight_key() {
local provider="$1" model="${2:-}" key_env="" key_hint=""
# claude-code carries its OWN auth (CLAUDE_CODE_OAUTH_TOKEN, else
# ANTHROPIC_API_KEY) and needs no Ollama key. A bare "claude-code" has no "/",
# so the caller's provider falls back to ollama-cloud and the table below
# would skip a perfectly configured reviewer.
#
# opencode is deliberately NOT exempt: that engine drives an ollama-cloud
# model through the bundled CLI and authenticates with OLLAMA_API_KEY, so it
# needs exactly the key the table checks. Exempting it — which an earlier
# version of this guard did — turns the pre-flight off for the one engine
# whose missing key it could still catch.
model="$(printf '%s' "$model" | tr -d '[:space:]')" # Go trims GADFLY_MODEL
case "$model" in
claude-code|claude-code/*) echo ""; return 0 ;;
esac
# Trim before testing: resolveModel does strings.TrimSpace on GADFLY_BASE_URL,
# so a whitespace-only value takes the REGISTRY path there. Testing the raw
# value here would call it "set", skip the check, and let the missing key
# arrive as a 401 with no notice — the two must agree on what "unset" means.
local base_url
base_url="$(printf '%s' "${GADFLY_BASE_URL:-}" | tr -d '[:space:]')"
if [ -n "$base_url" ]; then
# Endpoint-override path. Most providers take their credential from
# GADFLY_API_KEY here with a client-specific fallback, and those rules are
# not worth restating — this stays silent for them.
#
# The built-ins are the exception, and only since they gained an own-key
# fallback: a keyless kimi/qwen endpoint reads QWEN_API_KEY / KIMI_API_KEY
# on THIS path too, so "own key or GADFLY_API_KEY" is a rule that can be
# stated exactly. Leaving them unchecked here would let a keyless override
# config sail past the pre-flight and fail as a 401 — the failure the
# pre-flight exists to replace.
case "$provider" in
qwen|kimi) ;;
*) echo ""; return 0 ;;
esac
local own_env="$(printf '%s' "$provider" | tr '[:lower:]-' '[:upper:]_')_API_KEY"
if [ -n "${!own_env:-}" ] || [ -n "${GADFLY_API_KEY:-}" ]; then
echo ""
else
echo "$own_env"
fi
return 0
fi
local row
row="$(_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}')"
if [ -z "$row" ]; then
echo "" # provider needs no pre-flight
return 0
fi
key_env="$(printf '%s' "$row" | cut -d: -f2)"
key_hint="$(printf '%s' "$row" | cut -d: -f3)"
[ -n "$key_hint" ] || key_hint="$key_env"
# Indirect expansion (bash). Each majordomo built-in reads ONLY its own
# variable — cross-provider fallback is refused by design — so the named hint
# is always the actual fix.
if [ -n "${!key_env:-}" ]; then
echo ""
return 0
fi
echo "$key_hint"
}
# _gadfly_preflight_table is the single source for both the credential lookup
# and the provider list: "<provider>:<env-var-read>:<env-var-to-suggest>".
#
# The third field is normally empty, meaning "same as the second". ollama-cloud
# is the exception: run.sh copies the consumer-facing OLLAMA_CLOUD_API_KEY onto
# the OLLAMA_API_KEY the provider reads BEFORE calling in here, so the check and
# the hint name different variables on purpose. If that copy ever moves after
# the call, this arm reports a missing key for a configured run.
#
# A provider absent from this table is absent for one of TWO reasons — do not
# assume the first and add a row:
# 1. It needs no key, or carries one in its endpoint/DSN: local ollama,
# llama-swap, foreman.
# 2. It needs a key but accepts more than one variable, so a single-name check
# would skip a correctly-configured run. **google** is this case
# (GOOGLE_API_KEY *or* GEMINI_API_KEY); pre-flighting it needs an
# either-variable check, not this table's one-name shape.
_gadfly_preflight_table() {
printf '%s\n' \
'ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'opencode:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'open-code:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'qwen:QWEN_API_KEY:' \
'kimi:KIMI_API_KEY:' \
'openai:OPENAI_API_KEY:' \
'openai-compatible:OPENAI_API_KEY:' \
'anthropic:ANTHROPIC_API_KEY:'
}
# gadfly_preflight_providers echoes every provider covered above, one per line.
# Callers ASK rather than parse: a Go test cross-checks this against the
# openai-compat provider table in cmd/gadfly/model.go, and regexing this file
# would make its formatting a contract no linter enforces.
#
# The cross-check runs ONE direction — every openai-compat provider in Go must
# appear here. The reverse is not required and must not be asserted:
# ollama-cloud and anthropic belong in this table and are deliberately not in
# that Go list.
gadfly_preflight_providers() {
_gadfly_preflight_table | cut -d: -f1
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Table test for the credential pre-flight in preflight.sh.
#
# It SOURCES the real implementation rather than copying it, so there is no
# second definition that can pass while production fails.
#
# Run: scripts/preflight_test.sh (exit 0 = all cases pass)
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=preflight.sh
. "$SCRIPT_DIR/preflight.sh"
fail=0
check() { # description, want, got
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1 — want '$2', got '$3'"
fail=1
fi
}
# probe <provider> [VAR=VAL ...] — run the real function in a clean environment
# under the same shell options production uses (set -u), so an unset-variable
# bug surfaces here instead of in a live review.
probe() {
local provider="$1" model="${GADFLY_TEST_MODEL:-}"; shift
env -i PATH="$PATH" HOME="$HOME" "$@" bash -c "
set -u
. '$SCRIPT_DIR/preflight.sh'
gadfly_preflight_key '$provider' '$model'
"
}
echo "== registry path: keyed providers with no key must name their variable =="
check "qwen, no key" "QWEN_API_KEY" "$(probe qwen)"
check "kimi, no key" "KIMI_API_KEY" "$(probe kimi)"
check "ollama-cloud, no key" "OLLAMA_CLOUD_API_KEY" "$(probe ollama-cloud)"
check "openai, no key" "OPENAI_API_KEY" "$(probe openai)"
check "openai-compatible, none" "OPENAI_API_KEY" "$(probe openai-compatible)"
check "anthropic, no key" "ANTHROPIC_API_KEY" "$(probe anthropic)"
echo "== registry path: the provider's own key lets it run =="
check "qwen, keyed" "" "$(probe qwen QWEN_API_KEY=k)"
check "kimi, keyed" "" "$(probe kimi KIMI_API_KEY=k)"
check "ollama-cloud, keyed" "" "$(probe ollama-cloud OLLAMA_API_KEY=k)"
check "openai-compatible, keyed" "" "$(probe openai-compatible OPENAI_API_KEY=k)"
echo "== a wrong-provider key never satisfies a provider (no cross-fallback) =="
check "qwen w/ only OPENAI key" "QWEN_API_KEY" "$(probe qwen OPENAI_API_KEY=k)"
check "kimi w/ only QWEN key" "KIMI_API_KEY" "$(probe kimi QWEN_API_KEY=k)"
echo "== an empty-string key counts as missing, not present =="
check "qwen, empty key" "QWEN_API_KEY" "$(probe qwen QWEN_API_KEY=)"
echo "== GADFLY_API_KEY does NOT substitute on the registry path =="
# resolveModel reads GADFLY_API_KEY only after its `baseURL == ""` early
# return, so on this path the built-in reads its own variable and a set
# GADFLY_API_KEY changes nothing. Treating it as sufficient was a false pass.
check "qwen w/ GADFLY_API_KEY only" "QWEN_API_KEY" "$(probe qwen GADFLY_API_KEY=k)"
echo "== override path: built-ins ARE checked; others are not =="
# The credential there is GADFLY_API_KEY with a client-specific fallback, and
# the built-ins' own variables are never read. Checking one path's rules
# against the other produced a false pass in BOTH directions, so this path is
# left alone rather than guessed at.
# A built-in reads its own key on the override path too (openAICompatOptions
# falls back to QWEN_API_KEY/KIMI_API_KEY there), so "own key or GADFLY_API_KEY"
# is statable and worth checking — leaving it unchecked let a keyless config
# sail past and fail as a 401.
check "qwen + BASE_URL, no keys" "QWEN_API_KEY" "$(probe qwen GADFLY_BASE_URL=https://x)"
check "qwen + BASE_URL + own key" "" "$(probe qwen GADFLY_BASE_URL=https://x QWEN_API_KEY=k)"
check "qwen + BASE_URL + GADFLY key" "" "$(probe qwen GADFLY_BASE_URL=https://x GADFLY_API_KEY=k)"
check "kimi + BASE_URL, no keys" "KIMI_API_KEY" "$(probe kimi GADFLY_BASE_URL=https://x)"
# Other providers' override-path rules are not statable, so this stays quiet.
check "openai + BASE_URL, no keys" "" "$(probe openai GADFLY_BASE_URL=https://x)"
check "anthropic + BASE_URL, none" "" "$(probe anthropic GADFLY_BASE_URL=https://x)"
echo "== providers needing no key are never blocked, with nothing set =="
for p in ollama llama-swap llama-swaps llamaswap llamaswaps foreman google gemini some-dsn-name; do
check "unkeyed $p" "" "$(probe "$p")"
done
# google is absent from the table on purpose: it accepts GOOGLE_API_KEY *or*
# GEMINI_API_KEY, so a one-name arm would skip a correctly-configured run.
check "google w/ only GEMINI_API_KEY" "" "$(probe google GEMINI_API_KEY=k)"
echo "== a whitespace-only GADFLY_BASE_URL counts as unset, as it does in Go =="
# resolveModel TrimSpaces it and takes the registry path; if this check
# disagreed, the missing key would arrive as a bare 401 with no skip notice.
check "qwen + blank BASE_URL" "QWEN_API_KEY" "$(probe qwen GADFLY_BASE_URL=" ")"
echo "== engine specs carry their own auth and are never pre-flighted =="
# A bare "claude-code" has no "/", so the caller's provider falls back to
# ollama-cloud; judging it by that would skip a reviewer using
# CLAUDE_CODE_OAUTH_TOKEN, which needs no Ollama key.
check "bare claude-code, no ollama key" "" "$(GADFLY_TEST_MODEL=claude-code probe ollama-cloud)"
# Go trims GADFLY_MODEL, so padding must not bypass the exemption.
check "claude-code w/ whitespace" "" "$(GADFLY_TEST_MODEL=" claude-code " probe ollama-cloud)"
check "claude-code/opus, no ollama key" "" "$(GADFLY_TEST_MODEL=claude-code/opus probe ollama-cloud)"
# opencode is NOT exempt: it drives an ollama-cloud model and needs that key,
# so skipping it would disable the pre-flight for the one engine it can help.
check "opencode/x, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=opencode/x probe opencode)"
check "bare opencode, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=opencode probe ollama-cloud)"
check "open-code/x, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=open-code/x probe open-code)"
check "opencode/x, keyed" "" "$(GADFLY_TEST_MODEL=opencode/x probe opencode OLLAMA_API_KEY=k)"
# ...but a genuine ollama-cloud model still is.
check "ollama-cloud model, no key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=glm-5.2:cloud probe ollama-cloud)"
if [ "$fail" -ne 0 ]; then
echo "RESULT: preflight table FAILED"
exit 1
fi
echo "RESULT: all pre-flight cases pass"
+18 -4
View File
@@ -29,6 +29,13 @@
# tuning are read straight from the inherited environment — same as the other
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
#
# opencode engine: when MODEL is "opencode" or "opencode/<model>" the binary
# shells out to the bundled `opencode` CLI, driving an ollama-cloud model (for
# benchmarking against the majordomo path on the same model). Its auth reuses
# OLLAMA_CLOUD_API_KEY (mapped to OLLAMA_API_KEY below, same as the ollama-cloud
# path) and GADFLY_OPENCODE_* tuning is read from the inherited environment — so
# no extra wiring is needed here either.
#
# Optional:
# MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000)
# GADFLY_STATUS_FILE per-model JSON path for the live status board (set by
@@ -41,6 +48,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}"
# Credential pre-flight, shared verbatim with scripts/preflight_test.sh so the
# tested logic and the running logic are the same bytes.
# shellcheck source=preflight.sh
. "$SCRIPT_DIR/preflight.sh"
: "${GITEA_API:?GITEA_API required}"
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${PR:?PR required}"
@@ -155,10 +167,12 @@ case "$PROVIDER" in
fi
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
# Only the default cloud provider strictly needs a key up front; local Ollama
# and other providers either need none or read their own standard env var.
if [ "$GADFLY_PROVIDER_EFF" = "ollama-cloud" ] && [ -z "${OLLAMA_API_KEY:-}" ] && [ -z "${GADFLY_API_KEY:-}" ]; then
REVIEW="⚠️ No Ollama Cloud key configured (set \`OLLAMA_CLOUD_API_KEY\`) and \`GADFLY_PROVIDER\` is the default \`ollama-cloud\`; this reviewer was skipped."
# Credential pre-flight — one definition, shared with preflight_test.sh.
# Pass the raw spec too: engine specs (claude-code/opencode) carry their
# own auth and must not be judged by the provider fallback.
MISSING_KEY="$(gadfly_preflight_key "$GADFLY_PROVIDER_EFF" "$MODEL")"
if [ -n "$MISSING_KEY" ]; then
REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${MISSING_KEY}\`); this reviewer was skipped."
else
BIN="${GADFLY_BIN:-gadfly}"
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then