feat: kimi (Moonshot AI) built-in provider (ADR-0026)
CI / Tidy (pull_request) Successful in 9m29s
CI / Build & Test (pull_request) Successful in 10m23s
Gadfly review (reusable) / review (pull_request) Successful in 18m38s
Adversarial Review (Gadfly) / review (pull_request) Successful in 18m38s

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:
2026-07-18 02:59:30 -04:00
co-authored by Claude Opus 4.8
parent a9c864d3f7
commit 2bfffff47a
10 changed files with 285 additions and 10 deletions
+1
View File
@@ -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=...
+12 -1
View File
@@ -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://[email protected]/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
+31
View File
@@ -14,6 +14,10 @@ import (
// Built-in provider names.
const (
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 +32,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 +82,29 @@ 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://[email protected]/v1 for China).
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("KIMI_API_KEY"),
)...), 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
+138
View File
@@ -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)
}
}
+60
View File
@@ -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://[email protected]/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/<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, 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.
+3
View File
@@ -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 |
+2 -2
View File
@@ -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
+21
View File
@@ -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://[email protected]/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.
+1 -1
View File
@@ -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))
+10
View File
@@ -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.
@@ -106,6 +115,7 @@ func New(opts ...Option) *Provider {
p := &Provider{
name: "openai",
apiKey: os.Getenv("OPENAI_API_KEY"),
apiKeyName: "OPENAI_API_KEY",
baseURL: defaultBaseURL,
client: http.DefaultClient,
caps: defaultCapabilities(),