feat(qwen): let Qwen (and Kimi) join the swarm #30

Merged
steve merged 12 commits from feat/qwen-provider into main 2026-08-12 22:46:04 +00:00
5 changed files with 88 additions and 46 deletions
Showing only changes of commit 14f8533e38 - Show all commits
+26 -2
View File
1
@@ -58,20 +58,44 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Configure private module access
# 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: |
git config --global url."https://${REGISTRY_USER}:${REGISTRY_PASSWORD}@gitea.stevedudenhoeffer.com/".insteadOf "https://gitea.stevedudenhoeffer.com/"
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
git config --global url."https://${REGISTRY_USER}:${REGISTRY_PASSWORD}@gitea.stevedudenhoeffer.com/".insteadOf "https://gitea.stevedudenhoeffer.com/"
go mod download
rm -f "$HOME/.gitconfig"
# Prove the scrub worked, and prove it against the whole home dir —
# checking only the file just deleted would pass no matter what, and
Outdated
Review

🟡 Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR

maintainability · flagged by 1 model

  • .gitea/workflows/build-image.yml:78-126 — The new test job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp GIT_CONFIG_GLOBAL, trap-based cleanup, base64 auth-header construction, then a $HOME-wide grep scrub-verification with three-way exit-code handling) directly in the YAML run: | block. Confirmed by reading the full file: the workflow's other run: steps (Set up Docker Buildx, Log in to the registry, Compute tags at lines 162-181, the tw…

🪰 Gadfly · advisory

🟡 **Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR** _maintainability · flagged by 1 model_ - `.gitea/workflows/build-image.yml:78-126` — The new `test` job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp `GIT_CONFIG_GLOBAL`, `trap`-based cleanup, base64 auth-header construction, then a `$HOME`-wide `grep` scrub-verification with three-way exit-code handling) directly in the YAML `run: |` block. Confirmed by reading the full file: the workflow's other `run:` steps (`Set up Docker Buildx`, `Log in to the registry`, `Compute tags` at lines 162-181, the tw… <sub>🪰 Gadfly · advisory</sub>
# the credential can also reach ~/.netrc or ~/.config/go/env.
test ! -e "$HOME/.gitconfig"
if grep -rqF "${REGISTRY_PASSWORD}" "$HOME" 2>/dev/null; then
echo "::error::registry credential still present under \$HOME after scrub"
exit 1
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
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
+19 -16
View File
@@ -26,10 +26,11 @@ const defaultProvider = "ollama-cloud"
// built-ins that ARE that client pointed elsewhere, so an explicit endpoint for
// either belongs on the same branch.
//
// This is a slice rather than three copies of a case list because there are
// three places that must agree — resolveModel's switch, endpointProvider's
// switch, and the test that pins them — and the first version of this change
// added the names to one switch and not the other.
// 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"}
func isOpenAICompatProvider(name string) bool {
@@ -38,13 +39,13 @@ func isOpenAICompatProvider(name string) bool {
// endpointProviderNames is the operator-facing list of providers that accept an
// explicit endpoint. resolveModel and endpointProvider accept the SAME set, so
// they share one message rather than each carrying a hand-maintained copy that
// drifts in order and spelling — which is exactly what happened when kimi/qwen
// were added to both switches.
// one message serves both rather than each carrying a copy that drifts in
// order and spelling.
//
// Keep every accepted spelling here, including aliases: the first version of
// this constant dropped "gemini", so the list written to prevent drift had
// already drifted from the switches it describes.
// 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"
2
@@ -98,10 +99,12 @@ func resolveModel() (llm.Model, error) {
}
// Endpoint override: construct the provider directly at the given URL.
// The openai-compat family (openai/openai-compatible/kimi/qwen) is matched
// by the shared predicate, not a repeated case list. The credential here is
// GADFLY_API_KEY; the built-ins' own KIMI_API_KEY / QWEN_API_KEY apply only
// on the registry path above, where GADFLY_BASE_URL is unset.
// 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) {
opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" {
@@ -279,8 +282,8 @@ 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 hand-copied case lists are how they drifted apart before.
// Same shared predicate as resolveModel: the two must accept an identical
// set, and a hand-copied case list cannot guarantee that.
if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
+23 -11
View File
@@ -75,13 +75,13 @@ func TestEndpointProvider(t *testing.T) {
// 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. Adding a name to one and not the other yields a
// provider that works when configured one way and errors the other, for no
// reason a user could guess — which is exactly what happened here on the first
// pass. Asserting both in one table is what makes the pair fail together.
// 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 rather than a fourth copy of the names: a test
// that pins a list against drift must not be able to drift from it.
// 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")
1
@@ -105,21 +105,33 @@ func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {
}
// TestEndpointProviderNamesAreAllAccepted keeps the operator-facing list
// honest. endpointProviderNames exists to stop two error messages drifting
// apart, but nothing tied it to the switches it describes — and its first
// version had already dropped the "gemini" alias, so the anti-drift list was
// itself drifted. Every name it advertises must actually resolve.
// 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
}
t.Run(name, func(t *testing.T) {
// 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)
}
})
}
}
+18 -13
View File
@@ -2,10 +2,8 @@
# Credential pre-flight for the agentic reviewer, in ONE definition.
#
# Sourced by run.sh (production) and by preflight_test.sh (the table test), so
# there is no second copy to drift. An earlier version of this change had the
# logic in run.sh and a duplicate in the test reconciled by a regex diff — that
# guard only covered the provider table and not the decision logic below, which
# is precisely the half that had the bug.
# 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
@@ -15,15 +13,16 @@
# 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 — i.e. GADFLY_BASE_URL unset. That is deliberate.
# With an explicit endpoint, resolveModel constructs the client directly and the
# credential is GADFLY_API_KEY, falling back to the client's own default
# (OPENAI_API_KEY for the openai family) — while the built-ins' own variables are
# never consulted. Checking one path's rules against the other produced a
# false-pass in BOTH directions across successive fixes here, so this checks the
# path whose rules it can state exactly and stays silent on the other. An
# override-path config is hand-written by definition; the registry path is the
# one somebody hits by adding a model id to a var and forgetting the secret.
# Scope: the REGISTRY path only — GADFLY_BASE_URL unset — and deliberately so.
Review

🟠 Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice

error-handling · flagged by 1 model

  • scripts/preflight.sh:16-25 / cmd/gadfly/model.go:77-81,159-161 — the preflight's stated rationale for skipping the override path is contradicted by the code. The preflight comment justifies excluding the GADFLY_BASE_URL-set path by claiming that on it "a built-in's own variable is never consulted." But openAICompatOptions — called on exactly that override path at model.go:159-161does consult the built-in's own variable: os.Getenv(builtinCompatKeyEnv(provider)) at `model.go…

🪰 Gadfly · advisory

🟠 **Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice** _error-handling · flagged by 1 model_ - **`scripts/preflight.sh:16-25` / `cmd/gadfly/model.go:77-81,159-161` — the preflight's stated rationale for skipping the override path is contradicted by the code.** The preflight comment justifies excluding the `GADFLY_BASE_URL`-set path by claiming that on it "a built-in's own variable is never consulted." But `openAICompatOptions` — called on exactly that override path at `model.go:159-161` — *does* consult the built-in's own variable: `os.Getenv(builtinCompatKeyEnv(provider))` at `model.go… <sub>🪰 Gadfly · advisory</sub>
# 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" key_env="" key_hint=""
1
@@ -43,6 +42,12 @@ gadfly_preflight_key() {
# `google) key_env="GOOGLE_API_KEY"` would silently skip every reviewer
# configured with GEMINI_API_KEY. Pre-flighting google needs an
# either-variable check, not this table's one-name shape.
# ollama-cloud is checked on OLLAMA_API_KEY but hinted as OLLAMA_CLOUD_API_KEY:
# run.sh copies the consumer-facing OLLAMA_CLOUD_API_KEY secret into the
# OLLAMA_API_KEY the provider reads, BEFORE calling this. The hint names the
# variable the operator actually sets; the check reads the one the code uses.
# If that copy ever moves after this call, this arm reports a missing key for
Review

🟡 Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace

error-handling · flagged by 1 model

  • scripts/preflight.sh:49 — Unicode whitespace edge case in GADFLY_BASE_URL "unset" detection. The bash side uses tr -d '[:space:]', which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's strings.TrimSpace removes all Unicode space characters as defined by unicode.IsSpace (e.g., non-breaking space \u00A0, zero-width space, etc.). If GADFLY_BASE_URL is set to a value containing only non-ASCII whitespace, Go treats it as em…

🪰 Gadfly · advisory

🟡 **Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace** _error-handling · flagged by 1 model_ * `scripts/preflight.sh:49` — Unicode whitespace edge case in `GADFLY_BASE_URL` "unset" detection. The bash side uses `tr -d '[:space:]'`, which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's `strings.TrimSpace` removes all Unicode space characters as defined by `unicode.IsSpace` (e.g., non-breaking space `\u00A0`, zero-width space, etc.). If `GADFLY_BASE_URL` is set to a value containing only non-ASCII whitespace, Go treats it as em… <sub>🪰 Gadfly · advisory</sub>
# a configured run.
case "$provider" in
ollama-cloud) key_env="OLLAMA_API_KEY"; key_hint="OLLAMA_CLOUD_API_KEY" ;;
qwen) key_env="QWEN_API_KEY"; key_hint="QWEN_API_KEY" ;;
1
+2 -4
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
# Table test for the credential pre-flight in preflight.sh.
#
# It SOURCES the real implementation rather than copying it. An earlier version
# duplicated the logic and reconciled the copies with a regex diff — which only
# covered the provider table and not the decision logic, i.e. exactly the half
# that had the bug. Sourcing removes the second copy entirely.
# 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