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://tok@dashscope.aliyuncs.com/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_ 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) } }