diff --git a/.env.example b/.env.example index 8a784f5..955c225 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,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" #ANTHROPIC_API_KEY=sk-ant-... #GOOGLE_API_KEY=... diff --git a/README.md b/README.md index 6ff4d7e..85ff031 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Chains are health-tracked per target: | Provider | Spec name | Key env var | Default endpoint | |----------|-----------|-------------|------------------| | OpenAI (+compatible) | `openai` | `OPENAI_API_KEY` | https://api.openai.com/v1 | +| Kimi (Moonshot AI) | `kimi` | `KIMI_API_KEY` | https://api.moonshot.ai/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 | @@ -128,6 +129,11 @@ Chains are health-tracked per target: | foreman | `foreman` | — (token via DSN) | requires an LLM_* DSN or `ollama.Foreman(url, token)` | | llama-swap | `llama-swap` | — (token via DSN) | requires an LLM_* DSN or `llamaswap.New(...)` | +Kimi is Moonshot AI's OpenAI-compatible Chat Completions endpoint, so it reuses +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://token@api.moonshot.cn/v1`. + OpenAI-compatible / Anthropic-compatible endpoints: construct the provider with a name and base URL and register it — @@ -159,7 +165,7 @@ m, _ := reg.Parse("m5/qwen3:30b,m1/qwen3:30b,thinking") ``` DSN format: `scheme://[token@]host[/path]`, scheme ∈ `foreman`, `ollama`, -`ollama-cloud`, `openai`, `anthropic`, `google`/`gemini`, `llama-swap`, +`ollama-cloud`, `openai`, `kimi`, `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 @@ -400,6 +406,7 @@ to build one. | Provider | Resolve/Parse | Chat | Streaming | Tools | Structured | Images | Env DSN | |----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | OpenAI (+compatible) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Kimi (Moonshot AI) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅ | | Anthropic (+compat) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Google (Gemini) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Ollama Cloud | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -420,6 +427,10 @@ transcription** (`audio`), **video generation** (`videogen`) — separate axes, not shown above — plus a `Health` probe and management methods on `*llamaswap.Provider`. +³ Kimi reuses the openai client, so image *inputs* are supported at the client +level; whether a call succeeds depends on the Moonshot model — only the vision +variants (e.g. `moonshot-v1-8k-vision-preview`) accept images. + 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 diff --git a/builtin.go b/builtin.go index 91434f3..1b39c05 100644 --- a/builtin.go +++ b/builtin.go @@ -2,6 +2,7 @@ package majordomo import ( "net/http" + "strings" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic" @@ -13,7 +14,11 @@ import ( // Built-in provider names. const ( - ProviderOpenAI = "openai" + ProviderOpenAI = "openai" + // ProviderKimi is Moonshot AI's Kimi models over their OpenAI-compatible + // Chat Completions endpoint. Reuses the openai client (like llama-swap); + // keyed by KIMI_API_KEY, default base URL kimiBaseURL. + ProviderKimi = "kimi" ProviderAnthropic = "anthropic" ProviderGoogle = "google" ProviderOllama = "ollama" @@ -28,6 +33,10 @@ const ( ProviderLlamaSwapTLS = "llama-swaps" ) +// kimiBaseURL is Moonshot AI's international OpenAI-compatible endpoint. The +// China endpoint (api.moonshot.cn/v1) is reachable via a kimi:// LLM_* DSN. +const kimiBaseURL = "https://api.moonshot.ai/v1" + // 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. @@ -74,6 +83,33 @@ func registerBuiltins(r *Registry, httpClient *http.Client) { )...), nil } + // Kimi (Moonshot AI): OpenAI-compatible Chat Completions, so it reuses the + // openai client (like llama-swap). Defaults to Moonshot's international + // endpoint and the KIMI_API_KEY credential. WithAPIKey is passed + // unconditionally — even empty — so an unset KIMI_API_KEY can never fall + // through to the openai client's OPENAI_API_KEY default; WithAPIKeyName + // makes the missing-key error name KIMI_API_KEY. + r.providers[ProviderKimi] = openai.New(openaiOpts( + openai.WithName(ProviderKimi), + openai.WithBaseURL(kimiBaseURL), + openai.WithAPIKey(r.envLookup("KIMI_API_KEY")), + openai.WithAPIKeyName("KIMI_API_KEY"), + )...) + // kimi:// DSN scheme: an OpenAI-compatible target labeled kimi, base URL + // from the DSN host (e.g. kimi://tok@api.moonshot.cn/v1 for China). Its + // credential is the DSN token, not KIMI_API_KEY, so the missing-key hint + // names the LLM_ 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 + } + // llama-swap: OpenAI-compatible chat + image generation + management // endpoints over a model-swapping proxy. Chat reuses the openai client // (provider/llamaswap delegates). Two schemes: "llama-swap" builds an diff --git a/builtin_kimi_test.go b/builtin_kimi_test.go new file mode 100644 index 0000000..7140ba0 --- /dev/null +++ b/builtin_kimi_test.go @@ -0,0 +1,171 @@ +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://tok@api.moonshot.cn/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) + } +} + +// TestKimiSchemeMissingToken: a kimi:// DSN with no token is fixed by adding one +// to the DSN, not by setting KIMI_API_KEY — so the missing-key hint names the +// defining LLM_ env var, never KIMI_API_KEY (which does nothing for a +// DSN-defined provider). +func TestKimiSchemeMissingToken(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://api.moonshot.cn/v1", // no token + }); err != nil { + t.Fatalf("LoadEnv: %v", err) + } + + m, err := r.Parse("kcn/moonshot-v1-8k") + 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_KCN") { + t.Errorf("message = %q, want it to name LLM_KCN", apiErr.Message) + } + if strings.Contains(apiErr.Message, "KIMI_API_KEY") { + t.Errorf("message = %q, must not name KIMI_API_KEY for a DSN provider", apiErr.Message) + } + if rt.req != nil { + t.Error("network was hit despite missing token") + } +} diff --git a/docs/adr/0026-kimi-builtin.md b/docs/adr/0026-kimi-builtin.md new file mode 100644 index 0000000..3b4751e --- /dev/null +++ b/docs/adr/0026-kimi-builtin.md @@ -0,0 +1,60 @@ +# ADR-0026: Kimi (Moonshot AI) built-in provider + +**Status:** Accepted — 2026-07-18 + +## Context + +Moonshot AI's Kimi models (Kimi K2, `moonshot-v1-*`, and the vision variants) +are served over an OpenAI-compatible Chat Completions API at +`https://api.moonshot.ai/v1` (`https://api.moonshot.cn/v1` for China), +authenticated with a bearer key. mort wants Kimi as a first-class failover +tier, so `kimi/kimi-k2-...` should parse, chain, and alias out of the box with +a dedicated `KIMI_API_KEY` env var — the same ergonomics as `openai`, +`anthropic`, and `google`. + +Two tensions: + +- The wire protocol is byte-for-byte OpenAI Chat Completions, so a hand-rolled + client would duplicate `provider/openai` for zero gain (ADR-0007 forbids it), + exactly as ADR-0015 found for llama-swap. +- The README's current stance is that arbitrary OpenAI-compatible endpoints + (Groq, Together, …) are *consumer-registered*, not baked in. Blessing Kimi as + a built-in is a deliberate, narrow exception justified by the north star: + mort names Kimi directly in its tiers, and a built-in with `KIMI_API_KEY` + keeps mort's config free of boilerplate `openai.New(WithName/WithBaseURL)` + wiring. + +## Decision + +- **No new package.** The `kimi` built-in and `kimi://` DSN scheme both + construct `provider/openai` pointed at the Moonshot base URL — the chat path + inherits every openai feature/fix automatically (like llama-swap's chat). +- The built-in reads its key through the registry's injected `envLookup` + (`KIMI_API_KEY` only — no `MOONSHOT_API_KEY` alias, per the project owner) so + it stays hermetically testable via `WithEnvLookup`. +- **`WithAPIKey` is passed unconditionally, even when empty.** `openai.New` + defaults its key to `OPENAI_API_KEY`; without an explicit override an unset + `KIMI_API_KEY` would silently authenticate Kimi with the OpenAI key. Passing + the (possibly empty) lookup result severs that fallthrough. +- New `openai.WithAPIKeyName("KIMI_API_KEY")` option customizes only the + synthetic-401 missing-key hint (default `OPENAI_API_KEY`), so a keyless kimi + call tells the operator the *right* variable to set. +- The default endpoint is the international host (`kimiBaseURL`). The China + endpoint (or any other host) is reachable with a `kimi://` DSN, e.g. + `LLM_KCN=kimi://token@api.moonshot.cn/v1`. The `kimi://` scheme is an + OpenAI-compatible target labeled `kimi` with the same key-name hint; it is + intentionally near-identical to `openai://` — its value is a clear name in + specs and error reporting. + +## Consequences + +- `kimi/` is first-class in Parse, chains, aliases, and health/failover + with no consumer wiring; model ids pass through verbatim (no catalog). +- Chat, streaming, tools, and structured output ride the openai client. Image + *inputs* work at the client level but only the Moonshot vision models accept + them (matrix footnote ³). +- `WithAPIKeyName` is a small, generally useful addition to `provider/openai`; + the default preserves existing behavior for every other openai-compat target. +- Blessing one third-party endpoint as a built-in sets a precedent; future ones + should clear the same bar (a named consumer needs it in-config), not be added + reflexively — `RegisterProvider`/`LLM_*` remain the path for the rest. diff --git a/docs/adr/README.md b/docs/adr/README.md index 93772eb..b75b582 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,3 +27,6 @@ One decision per file, append-only; supersede rather than rewrite. | [0021](0021-musicgen-interface.md) | musicgen — blocking Generate over an async job queue | Accepted | | [0022](0022-embeddings-rerank-interface.md) | embeddings + rerank interface | Accepted | | [0023](0023-image-doc-surfaces.md) | Wave-3 image + document surfaces (segmentation, colorize, face restore, OCR) | Accepted | +| [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 | diff --git a/env.go b/env.go index 5ccd553..bf7d9a8 100644 --- a/env.go +++ b/env.go @@ -26,8 +26,8 @@ var ErrUnknownProvider = errors.New("unknown provider") // authenticated with the bearer token "test-token". type DSN struct { // Scheme selects the provider implementation: "foreman", "ollama", - // "ollama-cloud", "openai", "anthropic", "google"/"gemini", or any - // custom scheme registered with RegisterScheme. + // "ollama-cloud", "openai", "kimi", "anthropic", "google"/"gemini", or + // any custom scheme registered with RegisterScheme. Scheme string // Token is the provider secret (bearer token or API key); empty = none. Token string diff --git a/parse_test.go b/parse_test.go index 53d2bc3..e338490 100644 --- a/parse_test.go +++ b/parse_test.go @@ -213,7 +213,9 @@ func TestBuiltinsResolve(t *testing.T) { r := newTestRegistry(t) // All built-in provider names resolve even before their client // implementations land (stub providers error only on use). - for _, name := range []string{"openai", "anthropic", "google", "ollama", "ollama-cloud", "foreman"} { + // Note: llama-swap is intentionally excluded — its no-URL built-in errors + // at Model() construction (not just on use), so it can't resolve here. + for _, name := range []string{"openai", "kimi", "anthropic", "google", "ollama", "ollama-cloud", "foreman"} { if _, err := r.Parse(name + "/anything"); err != nil { t.Errorf("Parse(%s/anything): %v", name, err) } diff --git a/progress.md b/progress.md index 3c718b7..300080d 100644 --- a/progress.md +++ b/progress.md @@ -264,3 +264,24 @@ tests flush out. cheap `Health(ctx)` probe (GET /health) for often-offline hosts. - Hermetic httptest coverage for every new wire shape + validation errors. - Consumer: mort's llamaswap media tool cluster (status/image/TTS/STT tools). + +## 2026-07-18 — Kimi (Moonshot AI) built-in provider (ADR-0026) + +- New built-in `kimi` provider + `kimi://` DSN scheme: Moonshot's + OpenAI-compatible Chat Completions endpoint, so both reuse `provider/openai` + (no new client, mirrors llama-swap's chat path). Default base URL + `https://api.moonshot.ai/v1`; China endpoint via + `LLM_KCN=kimi://token@api.moonshot.cn/v1`. +- Credential is `KIMI_API_KEY` (read through the registry's injected envLookup, + so it's 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`); the kimi built-in/scheme name `KIMI_API_KEY`. +- Hermetic tests (capturing RoundTripper): built-in base URL + bearer, missing + key names KIMI_API_KEY with no OPENAI fallthrough and no network hit, and the + kimi:// scheme round-trips against the China host. +- Docs kept in sync: README built-in table + DSN scheme list + support matrix + (footnote ³), `.env.example`, ADR-0026 (+ index; also backfilled the missing + 0024/0025 index rows). +- Consumer: mort names Kimi as a failover tier. diff --git a/provider/openai/model.go b/provider/openai/model.go index 4aa9822..af5a57b 100644 --- a/provider/openai/model.go +++ b/provider/openai/model.go @@ -82,7 +82,7 @@ func (m *model) do(ctx context.Context, req llm.Request, stream bool) (*http.Res Model: m.id, Status: http.StatusUnauthorized, Code: "missing_api_key", - Message: "no API key configured: set OPENAI_API_KEY or use WithAPIKey", + Message: "no API key configured: set " + m.p.apiKeyName + " or use WithAPIKey", } } body, err := json.Marshal(m.buildRequest(req, stream)) diff --git a/provider/openai/openai.go b/provider/openai/openai.go index f8956dd..a57dbe3 100644 --- a/provider/openai/openai.go +++ b/provider/openai/openai.go @@ -34,6 +34,7 @@ const defaultBaseURL = "https://api.openai.com/v1" type Provider struct { name string apiKey string + apiKeyName string baseURL string client *http.Client caps llm.Capabilities @@ -64,6 +65,14 @@ func WithHTTPClient(c *http.Client) Option { } } +// WithAPIKeyName sets the environment-variable name shown in the missing-key +// error (default "OPENAI_API_KEY"). Why: the same client serves compat +// endpoints keyed by other env vars (e.g. KIMI_API_KEY), and the error should +// name the one the operator actually needs to set. +func WithAPIKeyName(name string) Option { + return func(p *Provider) { p.apiKeyName = name } +} + // WithName overrides the registry name ("openai" by default). Why: the same // client serves many OpenAI-compatible endpoints, and each needs a distinct // name in "provider/model" specs and error reporting. @@ -104,11 +113,12 @@ func defaultCapabilities() llm.Capabilities { // 401-style *llm.APIError at request time, not at construction. func New(opts ...Option) *Provider { p := &Provider{ - name: "openai", - apiKey: os.Getenv("OPENAI_API_KEY"), - baseURL: defaultBaseURL, - client: http.DefaultClient, - caps: defaultCapabilities(), + name: "openai", + apiKey: os.Getenv("OPENAI_API_KEY"), + apiKeyName: "OPENAI_API_KEY", + baseURL: defaultBaseURL, + client: http.DefaultClient, + caps: defaultCapabilities(), } for _, opt := range opts { opt(p)