feat: kimi (Moonshot AI) built-in provider (ADR-0026)
Add a first-class `kimi` provider and `kimi://` DSN scheme for Moonshot AI's OpenAI-compatible Chat Completions API. Both reuse provider/openai (no new client, mirroring llama-swap's chat path). Default endpoint is the international host; the China endpoint is reachable via a kimi:// LLM_* DSN. - Credential is KIMI_API_KEY, read through the registry's injected envLookup so it stays hermetically testable. WithAPIKey is passed unconditionally so an unset KIMI_API_KEY can never fall through to the openai client's OPENAI_API_KEY default. - New openai.WithAPIKeyName option customizes the missing-key error hint (default OPENAI_API_KEY); kimi names KIMI_API_KEY. - Hermetic tests: built-in base URL + bearer, missing-key hint names KIMI_API_KEY with no OPENAI fallthrough and no network hit, kimi:// scheme round-trips against the China host. - Docs in sync: README built-in table + DSN scheme list + support matrix, .env.example, env.go DSN doc, ADR-0026 (+ index, backfilling 0024/0025), progress.md. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package majordomo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// kimiResponse 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 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).
|
||||
type captureRT struct {
|
||||
req *http.Request
|
||||
body string
|
||||
}
|
||||
|
||||
func (c *captureRT) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
c.req = r
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(c.body)),
|
||||
Header: make(http.Header),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestKimiBuiltin: the built-in "kimi" provider resolves in Parse, targets
|
||||
// Moonshot's default endpoint, and authenticates with KIMI_API_KEY.
|
||||
func TestKimiBuiltin(t *testing.T) {
|
||||
rt := &captureRT{body: kimiResponse}
|
||||
r := newTestRegistry(t,
|
||||
WithEnvLookup(func(k string) string {
|
||||
if k == "KIMI_API_KEY" {
|
||||
return "kimi-secret"
|
||||
}
|
||||
return ""
|
||||
}),
|
||||
WithHTTPClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
|
||||
if p, ok := r.Provider(ProviderKimi); !ok {
|
||||
t.Fatal("built-in kimi provider not registered")
|
||||
} else if p.Name() != ProviderKimi {
|
||||
t.Errorf("name = %q, want %q", p.Name(), ProviderKimi)
|
||||
}
|
||||
|
||||
m, err := r.Parse("kimi/kimi-k2-0711-preview")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := targetsOf(t, m); len(got) != 1 || got[0] != "kimi/kimi-k2-0711-preview" {
|
||||
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://api.moonshot.ai/v1/chat/completions"; rt.req.URL.String() != want {
|
||||
t.Errorf("URL = %q, want %q", rt.req.URL.String(), want)
|
||||
}
|
||||
if want := "Bearer kimi-secret"; rt.req.Header.Get("Authorization") != want {
|
||||
t.Errorf("Authorization = %q, want %q", rt.req.Header.Get("Authorization"), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKimiBuiltinMissingKey: with no KIMI_API_KEY the built-in fails fast with a
|
||||
// synthetic 401 whose hint names KIMI_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 TestKimiBuiltinMissingKey(t *testing.T) {
|
||||
rt := &captureRT{body: kimiResponse}
|
||||
r := newTestRegistry(t, WithHTTPClient(&http.Client{Transport: rt}))
|
||||
|
||||
m, err := r.Parse("kimi/kimi-k2-0711-preview")
|
||||
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, "KIMI_API_KEY") {
|
||||
t.Errorf("message = %q, want it to name KIMI_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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKimiScheme: a kimi:// LLM_* DSN defines a named provider on any Moonshot
|
||||
// host (here the China endpoint) that is first-class in Parse and carries the
|
||||
// DSN token as its bearer credential.
|
||||
func TestKimiScheme(t *testing.T) {
|
||||
rt := &captureRT{body: kimiResponse}
|
||||
r := newTestRegistry(t, WithHTTPClient(&http.Client{Transport: rt}))
|
||||
if err := r.LoadEnv(map[string]string{
|
||||
"LLM_KCN": "kimi://[email protected]/v1",
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadEnv: %v", err)
|
||||
}
|
||||
|
||||
m, err := r.Parse("kcn/moonshot-v1-8k")
|
||||
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://api.moonshot.cn/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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user