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 60 additions and 22 deletions
Showing only changes of commit d8b023efd6 - Show all commits
+4
View File
@@ -53,6 +53,10 @@ jobs:
test:
Review

🟠 New pull_request-triggered go test job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs

security · flagged by 1 model

🪰 Gadfly · advisory

🟠 **New pull_request-triggered `go test` job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs** _security · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
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:
1
+7 -4
View File
1
@@ -74,12 +74,15 @@ func openAICompatOptions(provider, baseURL, key, keyHint string) []openai.Option
// 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 == "" {
Outdated
Review

🟠 Missing-key hint on the GADFLY_ENDPOINT_ path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead*

security · flagged by 1 model

  • cmd/gadfly/model.go:82 / cmd/gadfly/model.go:337-338 — missing-key hint steers operators toward an unmasked credential variable. On the GADFLY_ENDPOINT_* path, when a builtin (qwen/kimi) has no explicit |<key> field and its own env var (QWEN_API_KEY/KIMI_API_KEY) is unset, openAICompatOptions leaves keyHint as "GADFLY_ENDPOINT_"+strings.ToUpper(name) (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by own != ""). The resulti…

🪰 Gadfly · advisory

🟠 **Missing-key hint on the GADFLY_ENDPOINT_* path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead** _security · flagged by 1 model_ - **`cmd/gadfly/model.go:82` / `cmd/gadfly/model.go:337-338` — missing-key hint steers operators toward an unmasked credential variable.** On the `GADFLY_ENDPOINT_*` path, when a builtin (qwen/kimi) has no explicit `|<key>` field *and* its own env var (`QWEN_API_KEY`/`KIMI_API_KEY`) is unset, `openAICompatOptions` leaves `keyHint` as `"GADFLY_ENDPOINT_"+strings.ToUpper(name)` (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by `own != ""`). The resulti… <sub>🪰 Gadfly · advisory</sub>
if own := os.Getenv(builtinCompatKeyEnv(provider)); own != "" {
key, keyHint = own, builtinCompatKeyEnv(provider)
key = os.Getenv(builtinCompatKeyEnv(provider))
}
}
opts = append(opts, openai.WithAPIKey(key), openai.WithAPIKeyName(keyHint))
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
+14 -6
View File
1
@@ -269,7 +269,7 @@ func TestBuiltinCompatProvidersNeverInheritOpenAIKey(t *testing.T) {
if err != nil {
t.Fatalf("resolveModel: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "GADFLY_API_KEY")
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
@@ -283,7 +283,7 @@ func TestBuiltinCompatProvidersNeverInheritOpenAIKey(t *testing.T) {
if err != nil {
t.Fatalf("Model: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "GADFLY_ENDPOINT_EP")
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
}
}
@@ -303,7 +303,7 @@ func leakServer(t *testing.T) (*httptest.Server, *[]string) {
return srv, &seen
}
func assertFailsClosed(t *testing.T, m llm.Model, seen *[]string, foreign, wantHint string) {
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")}})
@@ -313,15 +313,23 @@ func assertFailsClosed(t *testing.T, m llm.Model, seen *[]string, foreign, wantH
}
}
if len(*seen) > 0 {
t.Errorf("a keyless %s provider reached the network (%d request(s)) instead of failing closed", wantHint, len(*seen))
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")
}
if !strings.Contains(err.Error(), wantHint) {
t.Errorf("error = %v, want it to name %s so the operator knows what to set", err, wantHint)
// 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)
}
}
+21 -7
View File
1
@@ -36,28 +36,42 @@ gadfly_preflight_key() {
# 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
Outdated
Review

🟡 Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check

error-handling · flagged by 1 model

  • scripts/run.sh:173 / scripts/preflight.sh:39 — The claude-code case exemption in gadfly_preflight_key does not trim whitespace from the model parameter, while the Go binary trims GADFLY_MODEL. If a model spec has leading/trailing whitespace (e.g., "claude-code "), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an OLLAMA_CLOUD_API_KEY that the engine doesn't need. Suggested fix: Trim whitespace from `mode…

🪰 Gadfly · advisory

🟡 **Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check** _error-handling · flagged by 1 model_ * `scripts/run.sh:173` / `scripts/preflight.sh:39` — The `claude-code` case exemption in `gadfly_preflight_key` does not trim whitespace from the `model` parameter, while the Go binary trims `GADFLY_MODEL`. If a model spec has leading/trailing whitespace (e.g., `"claude-code "`), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an `OLLAMA_CLOUD_API_KEY` that the engine doesn't need. **Suggested fix:** Trim whitespace from `mode… <sub>🪰 Gadfly · advisory</sub>
case "$model" in
claude-code|claude-code/*) echo ""; return 0 ;;
esac
# Only the registry path has knowable credential rules — see above.
# 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:]')"
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>
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
Outdated
Review

awk|cut|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **awk|cut|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
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
# 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
# a configured run.
local row
row="$(_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}')"
if [ -z "$row" ]; then
1
+11 -2
View File
@@ -60,15 +60,22 @@ echo "== GADFLY_API_KEY does NOT substitute on the registry path =="
# 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 (GADFLY_BASE_URL set) is deliberately not pre-flighted =="
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.
check "qwen + BASE_URL, no keys" "" "$(probe qwen GADFLY_BASE_URL=https://x)"
# 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
@@ -89,6 +96,8 @@ echo "== engine specs carry their own auth and are never pre-flighted =="
# 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.