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:
@@ -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=...
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+32
-1
@@ -13,7 +13,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 +32,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 +82,29 @@ 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).
|
||||||
|
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
|
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+21
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user