feat(qwen): Alibaba Qwen built-in over Model Studio's OpenAI-compatible mode
Adds the `qwen` built-in provider and the `qwen://` DSN scheme, keyed by QWEN_API_KEY and defaulting to Model Studio's international host. Like kimi (ADR-0026) it is `provider/openai` pointed elsewhere — no new client. Model Studio serves the same models over two protocols, so the real decision was which wire format to speak. ADR-0027 records why it is the OpenAI one: down the anthropic client `ReasoningEffort` is ignored by design, structured output rides the first-party `output_config.format` mechanism the shim does not implement, and cached-token accounting reads Anthropic-only usage fields. Each of those fails silently rather than loudly, which is what makes the choice worth writing down. The shim stays reachable ad hoc via an `anthropic://` DSN. The kimi and qwen DSN factories were byte-identical, so they now share one `openaiCompatScheme` helper: the "credential comes from the DSN token, and the missing-key hint names LLM_<NAME>" rules hold by construction instead of by copy. Tests are hermetic and break-checked (all six fail on a deliberate mutation), including the reverse credential leak — a visible QWEN_API_KEY must not authenticate the openai built-in — and reasoning_effort asserted on the wire body, which is the ADR's load-bearing claim. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -7,6 +7,7 @@ OLLAMA_API_KEY=your-ollama-cloud-key-here
|
||||
# Built-in provider keys (each optional; only needed for the providers you use).
|
||||
#OPENAI_API_KEY=sk-...
|
||||
#KIMI_API_KEY=sk-... # Moonshot AI (Kimi); provider name "kimi"
|
||||
#QWEN_API_KEY=sk-... # Alibaba Model Studio (Qwen); provider name "qwen"
|
||||
#ANTHROPIC_API_KEY=sk-ant-...
|
||||
#GOOGLE_API_KEY=...
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ Chains are health-tracked per target:
|
||||
|----------|-----------|-------------|------------------|
|
||||
| OpenAI (+compatible) | `openai` | `OPENAI_API_KEY` | https://api.openai.com/v1 |
|
||||
| Kimi (Moonshot AI) | `kimi` | `KIMI_API_KEY` | https://api.moonshot.ai/v1 |
|
||||
| Qwen (Alibaba) | `qwen` | `QWEN_API_KEY` | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
|
||||
| Anthropic (+compatible) | `anthropic` | `ANTHROPIC_API_KEY` | https://api.anthropic.com |
|
||||
| Google (Gemini) | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | Gemini API (official SDK) |
|
||||
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` | https://ollama.com |
|
||||
@@ -134,6 +135,19 @@ the openai client (like llama-swap). The `kimi` built-in defaults to the
|
||||
international endpoint; reach the China endpoint (or any other host) with a
|
||||
`kimi://` DSN, e.g. `LLM_KCN=kimi://[email protected]/v1`.
|
||||
|
||||
Qwen is the same shape: Alibaba Model Studio's OpenAI-compatible mode, reusing
|
||||
the openai client. The `qwen` built-in defaults to the international
|
||||
(Singapore) host; reach the China host or a workspace-scoped regional one with
|
||||
a `qwen://` DSN, e.g.
|
||||
`LLM_QCN=qwen://[email protected]/compatible-mode/v1`. Model Studio
|
||||
also fronts the same models with an Anthropic-compatible `/v1/messages` shim —
|
||||
majordomo does **not** use it, because on that surface `reasoning_effort` is
|
||||
dropped, `Request.Schema` stops being enforced, and cached-token accounting
|
||||
disappears; see [ADR-0027](docs/adr/0027-qwen-builtin.md). Two Alibaba-side
|
||||
quirks are worth knowing: thinking is on by default for some models (e.g.
|
||||
`qwen3.7-plus`), and the Qwen3 open-source models require streaming while
|
||||
thinking, so buffered `Generate` calls want a Max/Plus model.
|
||||
|
||||
OpenAI-compatible / Anthropic-compatible endpoints: construct the provider
|
||||
with a name and base URL and register it —
|
||||
|
||||
@@ -165,7 +179,7 @@ m, _ := reg.Parse("m5/qwen3:30b,m1/qwen3:30b,thinking")
|
||||
```
|
||||
|
||||
DSN format: `scheme://[token@]host[/path]`, scheme ∈ `foreman`, `ollama`,
|
||||
`ollama-cloud`, `openai`, `kimi`, `anthropic`, `google`/`gemini`, `llama-swap`,
|
||||
`ollama-cloud`, `openai`, `kimi`, `qwen`, `anthropic`, `google`/`gemini`, `llama-swap`,
|
||||
`llama-swaps`, or any scheme you add with `RegisterScheme`. The token is the
|
||||
credential (bearer token / API key); the base URL is always `https://host[/path]`
|
||||
— except `llama-swap`, which builds `http://host[:port]` since it's local-first
|
||||
@@ -407,6 +421,7 @@ to build one.
|
||||
|----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| OpenAI (+compatible) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Kimi (Moonshot AI) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅ |
|
||||
| Qwen (Alibaba) | ✅ | ✅ | ✅ | ✅ | ✅⁴ | ✅⁴ | ✅ |
|
||||
| Anthropic (+compat) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Google (Gemini) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ollama Cloud | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -431,6 +446,13 @@ probe and management methods on `*llamaswap.Provider`.
|
||||
level; whether a call succeeds depends on the Moonshot model — only the vision
|
||||
variants (e.g. `moonshot-v1-8k-vision-preview`) accept images.
|
||||
|
||||
⁴ Qwen also reuses the openai client (ADR-0027), so both columns are present at
|
||||
the client level and gated by the Model Studio model you name: `json_schema`
|
||||
structured output is on the Max/Plus families, image inputs on the `qwen-vl-*`
|
||||
/ `qwen3-vl-*` models. `reasoning_effort` rides through as a top-level field —
|
||||
one reason the built-in speaks OpenAI-compat rather than Model Studio's
|
||||
Anthropic-compat shim.
|
||||
|
||||
Notes: Ollama has no native tool_choice — `"none"` drops the tools;
|
||||
`"required"`/named choices are best-effort ignored there. Ollama Cloud
|
||||
ignores the `format` field (verified live), so the provider also states
|
||||
|
||||
+55
-13
@@ -19,6 +19,15 @@ const (
|
||||
// Chat Completions endpoint. Reuses the openai client (like llama-swap);
|
||||
// keyed by KIMI_API_KEY, default base URL kimiBaseURL.
|
||||
ProviderKimi = "kimi"
|
||||
// ProviderQwen is Alibaba's Qwen models over Model Studio's
|
||||
// OpenAI-compatible Chat Completions endpoint. Reuses the openai client
|
||||
// (like kimi and llama-swap); keyed by QWEN_API_KEY, default base URL
|
||||
// qwenBaseURL. Why OpenAI-compat and not the Anthropic-compat endpoint
|
||||
// Model Studio also exposes: see ADR-0027 — the OpenAI surface is the
|
||||
// first-class one there (reasoning_effort, json_schema structured output,
|
||||
// cached_tokens accounting all ride it), while the Anthropic shim exists
|
||||
// mainly to host Claude Code.
|
||||
ProviderQwen = "qwen"
|
||||
ProviderAnthropic = "anthropic"
|
||||
ProviderGoogle = "google"
|
||||
ProviderOllama = "ollama"
|
||||
@@ -37,6 +46,32 @@ const (
|
||||
// China endpoint (api.moonshot.cn/v1) is reachable via a kimi:// LLM_* DSN.
|
||||
const kimiBaseURL = "https://api.moonshot.ai/v1"
|
||||
|
||||
// qwenBaseURL is Alibaba Model Studio's international (Singapore) endpoint in
|
||||
// OpenAI-compatible mode. The China endpoint
|
||||
// (dashscope.aliyuncs.com/compatible-mode/v1) and any regional host are
|
||||
// reachable via a qwen:// LLM_* DSN.
|
||||
const qwenBaseURL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
|
||||
// openaiCompatScheme builds the DSN factory shared by every built-in that is
|
||||
// "the openai client pointed somewhere else" (kimi, qwen, ...). The provider
|
||||
// is named after the LLM_<NAME> var that defined it, takes its credential from
|
||||
// the DSN token — not the built-in's own env var, which does nothing for a
|
||||
// DSN-defined provider — and so names that same LLM_<NAME> var in the
|
||||
// missing-key hint, matching the lazy-resolution key form in providerFor.
|
||||
//
|
||||
// wrap is the caller's option-decorator (it injects the registry's HTTP
|
||||
// client), so a DSN provider is built exactly like the eager built-ins.
|
||||
func openaiCompatScheme(wrap func(...openai.Option) []openai.Option) SchemeFactory {
|
||||
return func(name string, dsn DSN) (llm.Provider, error) {
|
||||
return openai.New(wrap(
|
||||
openai.WithName(name),
|
||||
openai.WithBaseURL(dsn.BaseURL()),
|
||||
openai.WithAPIKey(dsn.Token),
|
||||
openai.WithAPIKeyName("LLM_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_"))),
|
||||
)...), nil
|
||||
}
|
||||
}
|
||||
|
||||
// registerBuiltins installs the built-in providers and env-DSN scheme
|
||||
// factories into a fresh registry. httpClient, when non-nil, is used by
|
||||
// every provider and factory the registry itself constructs.
|
||||
@@ -96,19 +131,26 @@ func registerBuiltins(r *Registry, httpClient *http.Client) {
|
||||
openai.WithAPIKeyName("KIMI_API_KEY"),
|
||||
)...)
|
||||
// kimi:// DSN scheme: an OpenAI-compatible target labeled kimi, base URL
|
||||
// from the DSN host (e.g. kimi://[email protected]/v1 for China). Its
|
||||
// credential is the DSN token, not KIMI_API_KEY, so the missing-key hint
|
||||
// names the LLM_<NAME> env var that defines this provider (matching the
|
||||
// lazy-resolution key form in providerFor) — the fix for a keyless target
|
||||
// here is adding a token to that DSN.
|
||||
r.schemes[ProviderKimi] = func(name string, dsn DSN) (llm.Provider, error) {
|
||||
return openai.New(openaiOpts(
|
||||
openai.WithName(name),
|
||||
openai.WithBaseURL(dsn.BaseURL()),
|
||||
openai.WithAPIKey(dsn.Token),
|
||||
openai.WithAPIKeyName("LLM_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_"))),
|
||||
)...), nil
|
||||
}
|
||||
// from the DSN host (e.g. kimi://[email protected]/v1 for China).
|
||||
r.schemes[ProviderKimi] = openaiCompatScheme(openaiOpts)
|
||||
|
||||
// Qwen (Alibaba Model Studio): same shape as kimi — an OpenAI-compatible
|
||||
// Chat Completions endpoint, so it reuses the openai client rather than a
|
||||
// new package. Model Studio also exposes an Anthropic-compatible endpoint;
|
||||
// ADR-0027 records why the OpenAI one is the built-in. Same unconditional
|
||||
// WithAPIKey + WithAPIKeyName discipline as kimi: an unset QWEN_API_KEY
|
||||
// must never fall through to OPENAI_API_KEY, and the missing-key error
|
||||
// must name the variable the operator actually has to set.
|
||||
r.providers[ProviderQwen] = openai.New(openaiOpts(
|
||||
openai.WithName(ProviderQwen),
|
||||
openai.WithBaseURL(qwenBaseURL),
|
||||
openai.WithAPIKey(r.envLookup("QWEN_API_KEY")),
|
||||
openai.WithAPIKeyName("QWEN_API_KEY"),
|
||||
)...)
|
||||
// qwen:// DSN scheme: an OpenAI-compatible target labeled qwen on any
|
||||
// Model Studio host (e.g. qwen://[email protected]/compatible-mode/v1
|
||||
// for China, or a workspace-scoped regional host).
|
||||
r.schemes[ProviderQwen] = openaiCompatScheme(openaiOpts)
|
||||
|
||||
// llama-swap: OpenAI-compatible chat + image generation + management
|
||||
// endpoints over a model-swapping proxy. Chat reuses the openai client
|
||||
|
||||
+12
-3
@@ -16,16 +16,25 @@ import (
|
||||
const kimiResponse = `{"id":"c1","object":"chat.completion","choices":[` +
|
||||
`{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`
|
||||
|
||||
// captureRT records the last request and returns a canned response without
|
||||
// touching the network, so these tests stay hermetic while still exercising
|
||||
// the real openai client the kimi built-in reuses (base URL + auth header).
|
||||
// captureRT records the last request (and the bytes of its body) and returns a
|
||||
// canned response without touching the network, so these tests stay hermetic
|
||||
// while still exercising the real openai client the kimi and qwen built-ins
|
||||
// reuse: base URL, auth header, and the JSON actually put on the wire.
|
||||
type captureRT struct {
|
||||
req *http.Request
|
||||
reqBody []byte
|
||||
body string
|
||||
}
|
||||
|
||||
func (c *captureRT) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
c.req = r
|
||||
// Drain and close the request body: a RoundTripper owns it, and those
|
||||
// bytes are what wire-shape assertions read.
|
||||
c.reqBody = nil
|
||||
if r.Body != nil {
|
||||
c.reqBody, _ = io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(c.body)),
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package majordomo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// qwenResponse is a minimal valid Chat Completions body so Generate returns a
|
||||
// non-empty response (an empty one would trigger failover, not a clean pass).
|
||||
const qwenResponse = `{"id":"c1","object":"chat.completion","choices":[` +
|
||||
`{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`
|
||||
|
||||
// TestQwenBuiltin: the built-in "qwen" provider resolves in Parse, targets
|
||||
// Model Studio's international OpenAI-compatible endpoint, and authenticates
|
||||
// with QWEN_API_KEY.
|
||||
func TestQwenBuiltin(t *testing.T) {
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t,
|
||||
WithEnvLookup(func(k string) string {
|
||||
if k == "QWEN_API_KEY" {
|
||||
return "qwen-secret"
|
||||
}
|
||||
return ""
|
||||
}),
|
||||
WithHTTPClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
|
||||
if p, ok := r.Provider(ProviderQwen); !ok {
|
||||
t.Fatal("built-in qwen provider not registered")
|
||||
} else if p.Name() != ProviderQwen {
|
||||
t.Errorf("name = %q, want %q", p.Name(), ProviderQwen)
|
||||
}
|
||||
|
||||
m, err := r.Parse("qwen/qwen3-max")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := targetsOf(t, m); len(got) != 1 || got[0] != "qwen/qwen3-max" {
|
||||
t.Fatalf("targets = %v", got)
|
||||
}
|
||||
|
||||
if _, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}}); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if rt.req == nil {
|
||||
t.Fatal("no request captured")
|
||||
}
|
||||
if want := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"; rt.req.URL.String() != want {
|
||||
t.Errorf("URL = %q, want %q", rt.req.URL.String(), want)
|
||||
}
|
||||
if want := "Bearer qwen-secret"; rt.req.Header.Get("Authorization") != want {
|
||||
t.Errorf("Authorization = %q, want %q", rt.req.Header.Get("Authorization"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenBuiltinMissingKey: with no QWEN_API_KEY the built-in fails fast with
|
||||
// a synthetic 401 whose hint names QWEN_API_KEY — never OPENAI_API_KEY (proving
|
||||
// the credential does not fall through to the openai client's default), and
|
||||
// without hitting the network.
|
||||
func TestQwenBuiltinMissingKey(t *testing.T) {
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t, WithHTTPClient(&http.Client{Transport: rt}))
|
||||
|
||||
m, err := r.Parse("qwen/qwen3-max")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
_, err = m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}})
|
||||
apiErr, ok := errors.AsType[*llm.APIError](err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v (%T), want *llm.APIError", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized || apiErr.Code != "missing_api_key" {
|
||||
t.Errorf("Status/Code = %d/%q, want 401/missing_api_key", apiErr.Status, apiErr.Code)
|
||||
}
|
||||
if !strings.Contains(apiErr.Message, "QWEN_API_KEY") {
|
||||
t.Errorf("message = %q, want it to name QWEN_API_KEY", apiErr.Message)
|
||||
}
|
||||
if strings.Contains(apiErr.Message, "OPENAI_API_KEY") {
|
||||
t.Errorf("message = %q, must not name OPENAI_API_KEY", apiErr.Message)
|
||||
}
|
||||
if rt.req != nil {
|
||||
t.Error("network was hit despite missing key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenBuiltinKeyDoesNotLeakToOpenAI: QWEN_API_KEY is the qwen built-in's
|
||||
// credential and nothing else's. Why this direction too: the fallthrough guard
|
||||
// only proves qwen never borrows OPENAI_API_KEY; this proves the reverse — a
|
||||
// registry that can see QWEN_API_KEY must not hand it to the openai built-in,
|
||||
// which would send an Alibaba key to api.openai.com.
|
||||
func TestQwenBuiltinKeyDoesNotLeakToOpenAI(t *testing.T) {
|
||||
// Set before newTestRegistry: the openai built-in reads OPENAI_API_KEY at
|
||||
// construction. Giving it a real key is what keeps this test honest — a
|
||||
// keyless openai target would 401 before any request, and the assertion
|
||||
// below would pass without a single byte reaching the wire.
|
||||
t.Setenv("OPENAI_API_KEY", "openai-secret")
|
||||
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t,
|
||||
WithEnvLookup(func(k string) string {
|
||||
if k == "QWEN_API_KEY" {
|
||||
return "qwen-secret"
|
||||
}
|
||||
return ""
|
||||
}),
|
||||
WithHTTPClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
|
||||
m, err := r.Parse("openai/gpt-4o-mini")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if _, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}}); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if rt.req == nil {
|
||||
t.Fatal("no request captured")
|
||||
}
|
||||
if want := "Bearer openai-secret"; rt.req.Header.Get("Authorization") != want {
|
||||
t.Errorf("Authorization = %q, want %q — the qwen credential must not reach the openai built-in",
|
||||
rt.req.Header.Get("Authorization"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenScheme: a qwen:// LLM_* DSN defines a named provider on any Model
|
||||
// Studio host (here the China endpoint) that is first-class in Parse and
|
||||
// carries the DSN token as its bearer credential.
|
||||
func TestQwenScheme(t *testing.T) {
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t, WithHTTPClient(&http.Client{Transport: rt}))
|
||||
if err := r.LoadEnv(map[string]string{
|
||||
"LLM_QCN": "qwen://[email protected]/compatible-mode/v1",
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadEnv: %v", err)
|
||||
}
|
||||
|
||||
m, err := r.Parse("qcn/qwen-plus")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if _, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}}); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if rt.req == nil {
|
||||
t.Fatal("no request captured")
|
||||
}
|
||||
if want := "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; rt.req.URL.String() != want {
|
||||
t.Errorf("URL = %q, want %q", rt.req.URL.String(), want)
|
||||
}
|
||||
if want := "Bearer tok"; rt.req.Header.Get("Authorization") != want {
|
||||
t.Errorf("Authorization = %q, want %q", rt.req.Header.Get("Authorization"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenSchemeMissingToken: a qwen:// DSN with no token is fixed by adding
|
||||
// one to the DSN, not by setting QWEN_API_KEY — so the missing-key hint names
|
||||
// the defining LLM_<NAME> env var, never QWEN_API_KEY (which does nothing for a
|
||||
// DSN-defined provider).
|
||||
func TestQwenSchemeMissingToken(t *testing.T) {
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t, WithHTTPClient(&http.Client{Transport: rt}))
|
||||
if err := r.LoadEnv(map[string]string{
|
||||
"LLM_QCN": "qwen://dashscope.aliyuncs.com/compatible-mode/v1", // no token
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadEnv: %v", err)
|
||||
}
|
||||
|
||||
m, err := r.Parse("qcn/qwen-plus")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
_, err = m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}})
|
||||
apiErr, ok := errors.AsType[*llm.APIError](err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v (%T), want *llm.APIError", err, err)
|
||||
}
|
||||
if !strings.Contains(apiErr.Message, "LLM_QCN") {
|
||||
t.Errorf("message = %q, want it to name LLM_QCN", apiErr.Message)
|
||||
}
|
||||
if strings.Contains(apiErr.Message, "QWEN_API_KEY") {
|
||||
t.Errorf("message = %q, must not name QWEN_API_KEY for a DSN provider", apiErr.Message)
|
||||
}
|
||||
if rt.req != nil {
|
||||
t.Error("network was hit despite missing token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenReasoningEffortReachesWire is the load-bearing test for ADR-0027's
|
||||
// central claim: Model Studio's OpenAI-compatible surface takes reasoning as a
|
||||
// top-level "reasoning_effort" body field, which the openai client already
|
||||
// sends — so llm.WithReasoningEffort survives the trip on qwen with no
|
||||
// qwen-specific code. Routing qwen through the anthropic client instead would
|
||||
// drop it silently (provider/anthropic ignores ReasoningEffort by design), and
|
||||
// that difference would be invisible without asserting on the wire body.
|
||||
func TestQwenReasoningEffortReachesWire(t *testing.T) {
|
||||
rt := &captureRT{body: qwenResponse}
|
||||
r := newTestRegistry(t,
|
||||
WithEnvLookup(func(k string) string {
|
||||
if k == "QWEN_API_KEY" {
|
||||
return "qwen-secret"
|
||||
}
|
||||
return ""
|
||||
}),
|
||||
WithHTTPClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
|
||||
m, err := r.Parse("qwen/qwen3-max")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
_, err = m.Generate(context.Background(), llm.Request{
|
||||
Messages: []llm.Message{llm.UserText("hi")},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if rt.reqBody == nil {
|
||||
t.Fatal("no request body captured")
|
||||
}
|
||||
var sent map[string]any
|
||||
if err := json.Unmarshal(rt.reqBody, &sent); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if got := sent["reasoning_effort"]; got != "high" {
|
||||
t.Errorf("reasoning_effort = %v, want %q (body: %s)", got, "high", rt.reqBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-0027: Qwen (Alibaba) built-in provider — OpenAI-compat, not Anthropic-compat
|
||||
|
||||
**Status:** Accepted — 2026-08-12
|
||||
|
||||
## Context
|
||||
|
||||
Alibaba's Qwen models (`qwen3.8-max`, `qwen3.7-plus`, the `qwen3-vl-*` vision
|
||||
variants, …) are served from Model Studio / DashScope, and mort wants them as a
|
||||
first-class failover tier with a dedicated `QWEN_API_KEY` — the same ergonomics
|
||||
ADR-0026 gave Kimi.
|
||||
|
||||
Unlike Kimi, Model Studio exposes the same models over **two** protocols:
|
||||
|
||||
| | OpenAI-compatible | Anthropic-compatible |
|
||||
|---|---|---|
|
||||
| Base URL | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||
| Endpoints | full Chat Completions surface | `/v1/messages` only (no `/v1/models`) |
|
||||
| Purpose | the documented developer API | a shim, documented around hosting Claude Code |
|
||||
|
||||
So the question this ADR answers is not "which client do we reuse" but
|
||||
"which of Alibaba's two wire protocols does the built-in speak".
|
||||
|
||||
## Decision
|
||||
|
||||
**The `qwen` built-in and the `qwen://` DSN scheme speak OpenAI-compat**, over
|
||||
`provider/openai` — no new package, mirroring ADR-0026 (kimi) and ADR-0015
|
||||
(llama-swap chat). Default base URL is the international host; the China host
|
||||
(`dashscope.aliyuncs.com/compatible-mode/v1`) and workspace-scoped regional
|
||||
hosts are reachable with a `qwen://` DSN.
|
||||
|
||||
Credential handling is copied from kimi verbatim, because both of its rules
|
||||
are load-bearing: `WithAPIKey` is passed unconditionally (even empty) so an
|
||||
unset `QWEN_API_KEY` can never fall through to `openai.New`'s `OPENAI_API_KEY`
|
||||
default, and `WithAPIKeyName("QWEN_API_KEY")` makes the synthetic-401 hint name
|
||||
the variable the operator actually has to set.
|
||||
|
||||
The kimi and qwen DSN factories were identical, so they now share one
|
||||
`openaiCompatScheme` helper — the next OpenAI-compat built-in gets the
|
||||
credential and key-hint rules by construction rather than by copy.
|
||||
|
||||
### Why not the Anthropic-compatible endpoint
|
||||
|
||||
Every concrete difference favors OpenAI-compat *for this codebase*:
|
||||
|
||||
- **Reasoning survives the trip.** Model Studio takes `reasoning_effort` as a
|
||||
top-level field on the OpenAI surface, which `provider/openai` already sends
|
||||
— `llm.WithReasoningEffort` works on qwen with zero qwen-specific code
|
||||
(`TestQwenReasoningEffortReachesWire` asserts it on the wire). Down the
|
||||
anthropic client it would be dropped in silence: `provider/anthropic`
|
||||
deliberately ignores `Request.ReasoningEffort`, because first-party Claude
|
||||
has no such knob.
|
||||
- **Structured output would regress.** `provider/anthropic` implements
|
||||
`Request.Schema` with the first-party GA `output_config.format` mechanism.
|
||||
Alibaba's shim does not implement it; a compat endpoint that ignores an
|
||||
unknown field returns unconstrained prose while still reporting success.
|
||||
The OpenAI path sends `response_format: json_schema`, which Model Studio
|
||||
supports natively on the Max/Plus families.
|
||||
- **Cache accounting already lands.** Model Studio's implicit prefix cache
|
||||
reports hits in `usage.prompt_tokens_details.cached_tokens`, which the openai
|
||||
client already maps to `llm.Usage.CacheReadTokens`. The anthropic client
|
||||
reads `cache_read_input_tokens`, a field the shim has no reason to emit.
|
||||
- **Thinking content is discarded on the anthropic path anyway.**
|
||||
`provider/anthropic` skips `thinking` blocks in both the buffered and
|
||||
streaming decoders, so the shim's headline feature — first-class
|
||||
`thinking: {type: "enabled", budget_tokens: N}` — buys majordomo nothing
|
||||
today.
|
||||
- **Smaller blast radius.** The anthropic client has no `WithAPIKeyName`
|
||||
option, so a keyless qwen would tell the operator to set `ANTHROPIC_API_KEY`;
|
||||
fixing that means changing the first-party Anthropic client to serve a
|
||||
third-party shim.
|
||||
- **It is the less-exercised surface.** The Anthropic endpoint is documented as
|
||||
Messages-only, with a temperature range that differs from Anthropic's own
|
||||
([0, 2) vs [0.0, 1.0]) — i.e. it is Qwen semantics wearing an Anthropic
|
||||
envelope, not an Anthropic-equivalent target.
|
||||
|
||||
The one thing the Anthropic surface offers that OpenAI-compat does not is
|
||||
explicit `cache_control` breakpoints reached through `Request.PromptCache`.
|
||||
That is not a reason to route Qwen through it: Model Studio's implicit cache is
|
||||
automatic and already metered, and if explicit breakpoints ever matter they
|
||||
belong in `provider/openai` (Model Studio accepts `cache_control` on content
|
||||
blocks there too), where every OpenAI-compat target would get them.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `qwen/<model>` is first-class in Parse, chains, aliases, and health/failover
|
||||
with no consumer wiring; model ids pass through verbatim (no catalog).
|
||||
- Chat, streaming, tools, structured output, reasoning effort, and cached-token
|
||||
accounting all ride the openai client and inherit its fixes.
|
||||
- Image *inputs* work at the client level, but only the `qwen-vl-*` /
|
||||
`qwen3-vl-*` models accept them (matrix footnote ³, shared with kimi).
|
||||
- Two model-side quirks are Alibaba's, not majordomo's, and are left to the
|
||||
caller rather than papered over: thinking is **on by default** on some models
|
||||
(e.g. `qwen3.7-plus`), and Qwen3 *open-source* models require streaming when
|
||||
thinking is enabled — a buffered `Generate` against one of those needs a
|
||||
model that supports non-streaming thinking (the Max/Plus families do).
|
||||
- If a future consumer genuinely needs the Anthropic surface, it is reachable
|
||||
today without library changes:
|
||||
`LLM_QWEN_ANTHROPIC=anthropic://[email protected]/apps/anthropic`
|
||||
— with the reasoning/structured-output caveats above.
|
||||
- Second third-party built-in after kimi. The ADR-0026 bar still holds: a named
|
||||
consumer needs it in-config. `RegisterProvider`/`LLM_*` remain the path for
|
||||
everything else.
|
||||
@@ -30,3 +30,4 @@ One decision per file, append-only; supersede rather than rewrite.
|
||||
| [0024](0024-audio-wave3-surfaces.md) | Wave-3 audio surfaces (stems, SFX, speech enhance, voice clone, translate) | Accepted |
|
||||
| [0025](0025-videogen-wave3-surfaces.md) | Wave-3 video surfaces (lipsync, video matte, video upscale, chain jobs) | Accepted |
|
||||
| [0026](0026-kimi-builtin.md) | Kimi (Moonshot AI) built-in provider — reuse openai client, KIMI_API_KEY | Accepted |
|
||||
| [0027](0027-qwen-builtin.md) | Qwen (Alibaba) built-in provider — OpenAI-compat, not Model Studio's Anthropic-compat endpoint | Accepted |
|
||||
|
||||
+29
@@ -285,3 +285,32 @@ tests flush out.
|
||||
(footnote ³), `.env.example`, ADR-0026 (+ index; also backfilled the missing
|
||||
0024/0025 index rows).
|
||||
- Consumer: mort names Kimi as a failover tier.
|
||||
|
||||
## 2026-08-12 — Qwen (Alibaba) built-in provider (ADR-0027)
|
||||
|
||||
- New built-in `qwen` provider + `qwen://` DSN scheme over Alibaba Model
|
||||
Studio's OpenAI-compatible mode, reusing `provider/openai` (no new client,
|
||||
mirrors kimi/llama-swap). Default base URL
|
||||
`https://dashscope-intl.aliyuncs.com/compatible-mode/v1`; China/regional
|
||||
hosts via `LLM_QCN=qwen://[email protected]/compatible-mode/v1`.
|
||||
- Credential is `QWEN_API_KEY` (via the registry's injected envLookup).
|
||||
`WithAPIKey` passed unconditionally so an unset key cannot fall through to
|
||||
`OPENAI_API_KEY`; `WithAPIKeyName` names `QWEN_API_KEY` in the 401 hint.
|
||||
- **Chose OpenAI-compat over Model Studio's Anthropic-compatible
|
||||
`/apps/anthropic` shim** (ADR-0027): on the anthropic client
|
||||
`ReasoningEffort` is ignored by design, `Request.Schema` rides
|
||||
`output_config.format` (which the shim does not implement), and cached-token
|
||||
accounting reads Anthropic-only usage fields. The shim is still reachable
|
||||
ad hoc via an `anthropic://` DSN.
|
||||
- kimi and qwen DSN factories were byte-identical, so they now share one
|
||||
`openaiCompatScheme` helper — the credential + key-hint rules come by
|
||||
construction, not by copy.
|
||||
- Hermetic tests: built-in base URL + bearer, missing key names QWEN_API_KEY
|
||||
with no OPENAI fallthrough and no network hit, the reverse leak (a visible
|
||||
QWEN_API_KEY must not authenticate the openai built-in), `qwen://` round-trip
|
||||
against the China host, its keyless hint naming LLM_QCN, and
|
||||
`reasoning_effort` asserted on the wire body (the ADR's load-bearing claim).
|
||||
captureRT now records the request body; all six break-checked.
|
||||
- Docs in sync: README built-in table + Qwen paragraph + DSN scheme list +
|
||||
support matrix (footnote ⁴), `.env.example`, ADR-0027 (+ index).
|
||||
- Consumer: mort wants Qwen as a failover tier.
|
||||
|
||||
Reference in New Issue
Block a user