Merge pull request 'feat: kimi (Moonshot AI) built-in provider (ADR-0026)' (#20) from feat/kimi-provider into main
CI / Tidy (push) Successful in 9m25s
CI / Build & Test (push) Successful in 10m17s

This commit was merged in pull request #20.
This commit is contained in:
2026-07-18 07:26:10 +00:00
11 changed files with 326 additions and 11 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). # Built-in provider keys (each optional; only needed for the providers you use).
#OPENAI_API_KEY=sk-... #OPENAI_API_KEY=sk-...
#KIMI_API_KEY=sk-... # Moonshot AI (Kimi); provider name "kimi"
#ANTHROPIC_API_KEY=sk-ant-... #ANTHROPIC_API_KEY=sk-ant-...
#GOOGLE_API_KEY=... #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 | | Provider | Spec name | Key env var | Default endpoint |
|----------|-----------|-------------|------------------| |----------|-----------|-------------|------------------|
| OpenAI (+compatible) | `openai` | `OPENAI_API_KEY` | https://api.openai.com/v1 | | 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 | | Anthropic (+compatible) | `anthropic` | `ANTHROPIC_API_KEY` | https://api.anthropic.com |
| Google (Gemini) | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | Gemini API (official SDK) | | Google (Gemini) | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | Gemini API (official SDK) |
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` | https://ollama.com | | 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)` | | 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(...)` | | 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 OpenAI-compatible / Anthropic-compatible endpoints: construct the provider
with a name and base URL and register it — 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`, 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 `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]` 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 — 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 | | Provider | Resolve/Parse | Chat | Streaming | Tools | Structured | Images | Env DSN |
|----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| |----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| OpenAI (+compatible) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | OpenAI (+compatible) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Kimi (Moonshot AI) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅³ | ✅ |
| Anthropic (+compat) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Anthropic (+compat) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Google (Gemini) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Google (Gemini) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Ollama Cloud | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Ollama Cloud | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -420,6 +427,10 @@ transcription** (`audio`), **video generation** (`videogen`) — separate
axes, not shown above — plus a `Health` axes, not shown above — plus a `Health`
probe and management methods on `*llamaswap.Provider`. 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; Notes: Ollama has no native tool_choice — `"none"` drops the tools;
`"required"`/named choices are best-effort ignored there. Ollama Cloud `"required"`/named choices are best-effort ignored there. Ollama Cloud
ignores the `format` field (verified live), so the provider also states ignores the `format` field (verified live), so the provider also states
+37 -1
View File
@@ -2,6 +2,7 @@ package majordomo
import ( import (
"net/http" "net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic" "gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic"
@@ -13,7 +14,11 @@ import (
// Built-in provider names. // Built-in provider names.
const ( 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" ProviderAnthropic = "anthropic"
ProviderGoogle = "google" ProviderGoogle = "google"
ProviderOllama = "ollama" ProviderOllama = "ollama"
@@ -28,6 +33,10 @@ const (
ProviderLlamaSwapTLS = "llama-swaps" 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 // registerBuiltins installs the built-in providers and env-DSN scheme
// factories into a fresh registry. httpClient, when non-nil, is used by // factories into a fresh registry. httpClient, when non-nil, is used by
// every provider and factory the registry itself constructs. // every provider and factory the registry itself constructs.
@@ -74,6 +83,33 @@ func registerBuiltins(r *Registry, httpClient *http.Client) {
)...), nil )...), 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). Its
// credential is the DSN token, not KIMI_API_KEY, so the missing-key hint
// names the LLM_<NAME> 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 // llama-swap: OpenAI-compatible chat + image generation + management
// endpoints over a model-swapping proxy. Chat reuses the openai client // endpoints over a model-swapping proxy. Chat reuses the openai client
// (provider/llamaswap delegates). Two schemes: "llama-swap" builds an // (provider/llamaswap delegates). Two schemes: "llama-swap" builds an
+171
View File
@@ -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://[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)
}
}
// 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_<NAME> 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")
}
}
+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 | | [0021](0021-musicgen-interface.md) | musicgen — blocking Generate over an async job queue | Accepted |
| [0022](0022-embeddings-rerank-interface.md) | embeddings + rerank interface | 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 | | [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". // authenticated with the bearer token "test-token".
type DSN struct { type DSN struct {
// Scheme selects the provider implementation: "foreman", "ollama", // Scheme selects the provider implementation: "foreman", "ollama",
// "ollama-cloud", "openai", "anthropic", "google"/"gemini", or any // "ollama-cloud", "openai", "kimi", "anthropic", "google"/"gemini", or
// custom scheme registered with RegisterScheme. // any custom scheme registered with RegisterScheme.
Scheme string Scheme string
// Token is the provider secret (bearer token or API key); empty = none. // Token is the provider secret (bearer token or API key); empty = none.
Token string Token string
+3 -1
View File
@@ -213,7 +213,9 @@ func TestBuiltinsResolve(t *testing.T) {
r := newTestRegistry(t) r := newTestRegistry(t)
// All built-in provider names resolve even before their client // All built-in provider names resolve even before their client
// implementations land (stub providers error only on use). // 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 { if _, err := r.Parse(name + "/anything"); err != nil {
t.Errorf("Parse(%s/anything): %v", name, err) t.Errorf("Parse(%s/anything): %v", name, err)
} }
+21
View File
@@ -264,3 +264,24 @@ tests flush out.
cheap `Health(ctx)` probe (GET /health) for often-offline hosts. cheap `Health(ctx)` probe (GET /health) for often-offline hosts.
- Hermetic httptest coverage for every new wire shape + validation errors. - Hermetic httptest coverage for every new wire shape + validation errors.
- Consumer: mort's llamaswap media tool cluster (status/image/TTS/STT tools). - 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, Model: m.id,
Status: http.StatusUnauthorized, Status: http.StatusUnauthorized,
Code: "missing_api_key", 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)) body, err := json.Marshal(m.buildRequest(req, stream))
+15 -5
View File
@@ -34,6 +34,7 @@ const defaultBaseURL = "https://api.openai.com/v1"
type Provider struct { type Provider struct {
name string name string
apiKey string apiKey string
apiKeyName string
baseURL string baseURL string
client *http.Client client *http.Client
caps llm.Capabilities 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 // WithName overrides the registry name ("openai" by default). Why: the same
// client serves many OpenAI-compatible endpoints, and each needs a distinct // client serves many OpenAI-compatible endpoints, and each needs a distinct
// name in "provider/model" specs and error reporting. // 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. // 401-style *llm.APIError at request time, not at construction.
func New(opts ...Option) *Provider { func New(opts ...Option) *Provider {
p := &Provider{ p := &Provider{
name: "openai", name: "openai",
apiKey: os.Getenv("OPENAI_API_KEY"), apiKey: os.Getenv("OPENAI_API_KEY"),
baseURL: defaultBaseURL, apiKeyName: "OPENAI_API_KEY",
client: http.DefaultClient, baseURL: defaultBaseURL,
caps: defaultCapabilities(), client: http.DefaultClient,
caps: defaultCapabilities(),
} }
for _, opt := range opts { for _, opt := range opts {
opt(p) opt(p)