feat(qwen): let Qwen (and Kimi) join the swarm #30
@@ -46,6 +46,11 @@ 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>" entry can join the swarm by editing the
|
||||
# GADFLY_DEFAULT_MODELS var alone — no workflow edit, no re-release.
|
||||
# Empty until the repo secret exists: that's a 401 on that one model,
|
||||
# not a broken review.
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
|
||||
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -82,6 +82,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 }
|
||||
@@ -127,6 +136,12 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -73,11 +73,22 @@ 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`.
|
||||
|
||||
> ### 🧪 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`)
|
||||
|
||||
+15
-4
@@ -68,7 +68,14 @@ func resolveModel() (llm.Model, error) {
|
||||
|
||||
// Endpoint override: construct the provider directly at the given URL.
|
||||
switch provider {
|
||||
case "openai", "openai-compatible":
|
||||
case "openai", "openai-compatible", "kimi", "qwen":
|
||||
// kimi (Moonshot) and qwen (Alibaba Model Studio) are majordomo
|
||||
// built-ins that ARE the openai client at a different base URL, so an
|
||||
// explicit GADFLY_BASE_URL for either belongs here. Without these names
|
||||
// the override fell through to default: and errored, even though both
|
||||
// resolve fine on the registry path above — a confusing asymmetry.
|
||||
// The credential here is GADFLY_API_KEY; the built-ins' own
|
||||
// KIMI_API_KEY / QWEN_API_KEY apply only when GADFLY_BASE_URL is unset.
|
||||
opts := []openai.Option{openai.WithBaseURL(baseURL)}
|
||||
if apiKey != "" {
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
@@ -108,7 +115,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 openai/openai-compatible/kimi/qwen/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +265,11 @@ 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":
|
||||
case "openai", "openai-compatible", "kimi", "qwen":
|
||||
// kimi/qwen accepted here for the same reason as in resolveModel: both
|
||||
// majordomo built-ins ARE the openai client at their own base URL, so a
|
||||
// named endpoint pointing at one (a regional Model Studio host, say)
|
||||
// must resolve, not error. The two switches move together.
|
||||
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
|
||||
if key != "" {
|
||||
opts = append(opts, openai.WithAPIKey(key))
|
||||
@@ -277,6 +288,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 ollama/llama-swap(s)/foreman/openai/openai-compatible/kimi/qwen/anthropic/google)", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,37 @@ 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. 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.
|
||||
func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {
|
||||
for _, provider := range []string{"openai", "openai-compatible", "kimi", "qwen"} {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+9
-1
@@ -33,9 +33,17 @@
|
||||
# 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.
|
||||
|
||||
+20
-4
@@ -162,10 +162,26 @@ 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."
|
||||
# Pre-flight the credential for providers that need one. Why bother, when
|
||||
# majordomo already fails closed with a 401: without this, a missing key
|
||||
# surfaces as five identical per-lens agent failures that name no variable,
|
||||
# and the operator has to read a stack trace to learn which secret they
|
||||
# forgot to forward. Providers absent from this table need no key (local
|
||||
# ollama, llama-swap) or carry it in their endpoint/DSN (foreman), and are
|
||||
# left alone. GADFLY_API_KEY overrides any of them.
|
||||
KEY_ENV=""; KEY_HINT=""
|
||||
case "$GADFLY_PROVIDER_EFF" 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" ;;
|
||||
kimi) KEY_ENV="KIMI_API_KEY"; KEY_HINT="KIMI_API_KEY" ;;
|
||||
openai) KEY_ENV="OPENAI_API_KEY"; KEY_HINT="OPENAI_API_KEY" ;;
|
||||
anthropic) KEY_ENV="ANTHROPIC_API_KEY"; KEY_HINT="ANTHROPIC_API_KEY" ;;
|
||||
esac
|
||||
# Indirect expansion (bash), so the table above stays a table. 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" ] && [ -z "${!KEY_ENV:-}" ] && [ -z "${GADFLY_API_KEY:-}" ]; then
|
||||
REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${KEY_HINT}\`, or \`GADFLY_API_KEY\`); this reviewer was skipped."
|
||||
else
|
||||
BIN="${GADFLY_BIN:-gadfly}"
|
||||
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then
|
||||
|
||||
Reference in New Issue
Block a user
🟡 TestOpenAICompatProvidersResolveOnBothPaths is mostly redundant with the stronger TestEndpointProviderNamesAreAllAccepted (only the Name() check is unique)
maintainability · flagged by 1 model
cmd/gadfly/model_test.go:90TestOpenAICompatProvidersResolveOnBothPathsis largely subsumed byTestEndpointProviderNamesAreAllAccepted(line 120). The latter rangesendpointProviderNames(model.go:100-101), which is a strict superset ofopenAICompatProviders(model.go:34 —openai/openai-compatible/kimi/qwen… plusollama/ollama-cloud/llama-swap/...), and asserts bothendpointProviderandresolveModelresolve without error for every name — the same "both paths" assertion t…🪰 Gadfly · advisory