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]>
This commit is contained in:
2026-08-12 16:30:29 -04:00
co-authored by Claude Opus 5
parent c9dab69d14
commit 0f40b21d79
7 changed files with 107 additions and 10 deletions
+15 -4
View File
@@ -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)
}
}
+31
View File
@@ -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