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]>
236 lines
8.3 KiB
Go
236 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|