fix(qwen): gadfly round 2 — the anti-drift list had already drifted
Build & push image / build-and-push (pull_request) Successful in 3s
Build & push image / build-and-push (pull_request) Successful in 3s
Eight findings, all real, and the sharpest ones are about this PR's own fixes. GADFLY_API_KEY was treated as a universal substitute in the pre-flight. It is not: resolveModel reads it only AFTER the `baseURL == ""` early return, so on the registry path — the documented primary path — a qwen/kimi built-in reads its own variable and GADFLY_API_KEY is never consulted. A mis-set GADFLY_API_KEY therefore passed pre-flight and 401'd five times anyway, which is precisely the failure this check exists to prevent. It now only substitutes when GADFLY_BASE_URL is also set. `openai-compatible` was missing from the pre-flight table while both switches accept it as an OPENAI_API_KEY alias, so that one spelling still fell through to the cryptic five-failure mode. endpointProviderNames — the constant I introduced *to stop* the two error messages drifting — omitted the `gemini` alias both switches accept. It now lists every accepted spelling. And the case list itself was still duplicated across both switches plus the test that pins them: three copies of the thing whose duplication started this. Both switches now call isOpenAICompatProvider over one shared slice, and endpointProvider's doc comment points at endpointProviderNames instead of carrying a fourth hand-written copy. scripts/preflight_test.sh moves into the repo (20 cases, up from 17, covering openai-compatible and both GADFLY_API_KEY directions). It carries a drift guard that diffs its copy of the provider table against run.sh's and aborts if they differ — break-checked by deleting an arm from run.sh, which fails it loudly. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
+42
-21
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
@@ -19,12 +20,33 @@ import (
|
||||
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
|
||||
const defaultProvider = "ollama-cloud"
|
||||
|
||||
// openAICompatProviders are the provider names that resolve to the plain
|
||||
// openai client at an explicit base URL. openai-compatible is the generic
|
||||
// spelling; kimi (Moonshot) and qwen (Alibaba Model Studio) are majordomo
|
||||
// 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.
|
||||
var openAICompatProviders = []string{"openai", "openai-compatible", "kimi", "qwen"}
|
||||
|
||||
func isOpenAICompatProvider(name string) bool {
|
||||
return slices.Contains(openAICompatProviders, name)
|
||||
}
|
||||
|
||||
// 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.
|
||||
const endpointProviderNames = "openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/llama-swap(s)/foreman/anthropic/google"
|
||||
//
|
||||
// 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.
|
||||
const endpointProviderNames = "openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/" +
|
||||
"llama-swap/llama-swaps/llamaswap/llamaswaps/foreman/anthropic/google/gemini"
|
||||
|
||||
// resolveModel builds the review model from the environment. Gadfly is powered
|
||||
// by majordomo, so it can target any provider majordomo supports — Ollama
|
||||
@@ -74,20 +96,19 @@ func resolveModel() (llm.Model, error) {
|
||||
}
|
||||
|
||||
// Endpoint override: construct the provider directly at the given URL.
|
||||
switch provider {
|
||||
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.
|
||||
// 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.
|
||||
if isOpenAICompatProvider(provider) {
|
||||
opts := []openai.Option{openai.WithBaseURL(baseURL)}
|
||||
if apiKey != "" {
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
}
|
||||
return openai.New(opts...).Model(model)
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "ollama", "ollama-cloud":
|
||||
opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
|
||||
if apiKey != "" {
|
||||
@@ -202,7 +223,7 @@ func modelProvider() string {
|
||||
// plaintext local Ollama (or foreman queue) works:
|
||||
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
|
||||
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
|
||||
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman"
|
||||
// provider is one of endpointProviderNames; "foreman"
|
||||
// targets a foreman daemon (native Ollama on the wire):
|
||||
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
|
||||
//
|
||||
@@ -254,6 +275,16 @@ 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.
|
||||
if isOpenAICompatProvider(provider) {
|
||||
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
|
||||
if key != "" {
|
||||
opts = append(opts, openai.WithAPIKey(key))
|
||||
}
|
||||
return openai.New(opts...), nil
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "ollama", "ollama-cloud":
|
||||
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
|
||||
@@ -272,16 +303,6 @@ 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", "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))
|
||||
}
|
||||
return openai.New(opts...), nil
|
||||
case "anthropic":
|
||||
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
|
||||
if key != "" {
|
||||
|
||||
Reference in New Issue
Block a user