diff --git a/.gitea/workflows/adversarial-review.yml b/.gitea/workflows/adversarial-review.yml index 1a329d0..dbd530f 100644 --- a/.gitea/workflows/adversarial-review.yml +++ b/.gitea/workflows/adversarial-review.yml @@ -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/" 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: diff --git a/.gitea/workflows/review-reusable.yml b/.gitea/workflows/review-reusable.yml index f1e7504..47bbdd6 100644 --- a/.gitea/workflows/review-reusable.yml +++ b/.gitea/workflows/review-reusable.yml @@ -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 diff --git a/README.md b/README.md index 3a333b3..825513b 100644 --- a/README.md +++ b/README.md @@ -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/` (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`) diff --git a/cmd/gadfly/model.go b/cmd/gadfly/model.go index 546641c..c25dd88 100644 --- a/cmd/gadfly/model.go +++ b/cmd/gadfly/model.go @@ -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) } } diff --git a/cmd/gadfly/model_test.go b/cmd/gadfly/model_test.go index d77c68f..d8ba84a 100644 --- a/cmd/gadfly/model_test.go +++ b/cmd/gadfly/model_test.go @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index 43147de..a9e3a7f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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/". 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/"); Pro/Max subscription # token from `claude setup-token`. Else ANTHROPIC_API_KEY. diff --git a/scripts/run.sh b/scripts/run.sh index 6dc2944..6230a23 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -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