Skip to content

Commit 3b74c4a

Browse files
committed
feat(providers): add kimi_coding provider with fixed User-Agent + temp lock
Moonshot's Kimi Coding endpoint is OpenAI-compatible on the wire but has two non-standard rules: 1. Every request must carry `User-Agent: claude-code/0.1.0` — without it the upstream rejects the call outright. 2. `temperature` is locked to the server default; passing any other value returns HTTP 400 `invalid temperature: only 1 is allowed for this model`. Rather than special-case either, this commit generalises both: - WithExtraHeaders on OpenAIProvider — static headers attached to every outgoing request. Reusable by any future provider that needs pinned identity headers; mirrored in adapter_openai.ToRequest so callers using the adapter path see the same shape. - The existing skipTemp branch in openai_request.go gets a provider_type check — kimi_coding joins o1/o3/o4/gpt-5-mini in omitting `temperature` from the request body. Provider wiring: - store.ProviderKimiCoding constant + ValidProviderTypes entry + KimiCoding{DefaultAPIBase,DefaultModel,RequiredUserAgent}. - case store.ProviderKimiCoding in both registration switches (cmd/gateway_providers.go and internal/http/providers.go). - UI dropdown entry with the API base pre-filled. 5 unit tests cover: real outgoing header injection, adapter-path header mirroring, empty-map WithExtraHeaders no-op, kimi_coding strips temperature, and the negative control (other providers still forward temperature). Admin flow: Providers → Add → "Kimi Coding (Moonshot)" → paste API key → save.
1 parent 543fedc commit 3b74c4a

9 files changed

Lines changed: 207 additions & 0 deletions

File tree

cmd/gateway_providers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,19 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
440440
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel)
441441
prov.WithProviderType(p.ProviderType)
442442
registry.RegisterForTenant(p.TenantID, prov)
443+
case store.ProviderKimiCoding:
444+
// Moonshot Kimi Coding requires a fixed User-Agent on every request.
445+
// OpenAI-compatible wire shape otherwise.
446+
base := p.APIBase
447+
if base == "" {
448+
base = store.KimiCodingDefaultAPIBase
449+
}
450+
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel)
451+
prov.WithProviderType(p.ProviderType)
452+
prov.WithExtraHeaders(map[string]string{
453+
"User-Agent": store.KimiCodingRequiredUserAgent,
454+
})
455+
registry.RegisterForTenant(p.TenantID, prov)
443456
default:
444457
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
445458
prov.WithProviderType(p.ProviderType)

internal/http/providers.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,18 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
262262
base = store.NovitaDefaultAPIBase
263263
}
264264
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel))
265+
case store.ProviderKimiCoding:
266+
// Moonshot Kimi Coding requires a fixed User-Agent on every request.
267+
base := apiBase
268+
if base == "" {
269+
base = store.KimiCodingDefaultAPIBase
270+
}
271+
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel)
272+
prov.WithProviderType(p.ProviderType)
273+
prov.WithExtraHeaders(map[string]string{
274+
"User-Agent": store.KimiCodingRequiredUserAgent,
275+
})
276+
h.providerReg.RegisterForTenant(p.TenantID, prov)
265277
default:
266278
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "")
267279
if p.ProviderType == store.ProviderMiniMax {

internal/providers/adapter_openai.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ func (a *OpenAIAdapter) ToRequest(req ChatRequest) ([]byte, http.Header, error)
6161
if a.provider.siteTitle != "" {
6262
h.Set("X-Title", a.provider.siteTitle)
6363
}
64+
// Mirror doRequest: provider-static headers (e.g. kimi_coding User-Agent).
65+
for k, v := range a.provider.extraHeaders {
66+
h.Set(k, v)
67+
}
6468

6569
return data, h, nil
6670
}

internal/providers/openai_config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type OpenAIProvider struct {
1717
providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native")
1818
siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer)
1919
siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title)
20+
extraHeaders map[string]string // static headers set on every outgoing request (e.g. fixed User-Agent for kimi_coding)
2021
client *http.Client
2122
retryConfig RetryConfig
2223
middlewares RequestMiddleware // composed middleware chain (nil = no-op)
@@ -63,6 +64,36 @@ func (p *OpenAIProvider) WithSiteInfo(url, title string) *OpenAIProvider {
6364
return p
6465
}
6566

67+
// WithExtraHeaders sets static headers attached to every outgoing request.
68+
// Used by providers that require a fixed identity header (e.g. kimi_coding's
69+
// User-Agent: claude-code/0.1.0). Repeat calls merge — keys already present are
70+
// overwritten. Passing an empty map is a no-op.
71+
func (p *OpenAIProvider) WithExtraHeaders(h map[string]string) *OpenAIProvider {
72+
if len(h) == 0 {
73+
return p
74+
}
75+
if p.extraHeaders == nil {
76+
p.extraHeaders = make(map[string]string, len(h))
77+
}
78+
for k, v := range h {
79+
p.extraHeaders[k] = v
80+
}
81+
return p
82+
}
83+
84+
// ExtraHeaders returns a copy of the static headers configured for this provider.
85+
// Used by adapter_openai.go to mirror the runtime request headers.
86+
func (p *OpenAIProvider) ExtraHeaders() map[string]string {
87+
if len(p.extraHeaders) == 0 {
88+
return nil
89+
}
90+
out := make(map[string]string, len(p.extraHeaders))
91+
for k, v := range p.extraHeaders {
92+
out[k] = v
93+
}
94+
return out
95+
}
96+
6697
// WithRegistry sets the model registry for forward-compat resolution.
6798
func (p *OpenAIProvider) WithRegistry(r ModelRegistry) *OpenAIProvider {
6899
p.registry = r
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package providers
2+
3+
// Coverage for OpenAIProvider.WithExtraHeaders — the mechanism Kimi Coding
4+
// uses to send a fixed User-Agent on every request.
5+
6+
import (
7+
"context"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
)
13+
14+
// TestOpenAIProvider_ExtraHeaders_AppliedOnHTTPRequest verifies that headers
15+
// set via WithExtraHeaders reach the actual outgoing request — not just the
16+
// adapter's header map.
17+
func TestOpenAIProvider_ExtraHeaders_AppliedOnHTTPRequest(t *testing.T) {
18+
var gotUserAgent, gotXTrace, gotAuth string
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
gotUserAgent = r.Header.Get("User-Agent")
21+
gotXTrace = r.Header.Get("X-Trace-Id")
22+
gotAuth = r.Header.Get("Authorization")
23+
// Minimal non-stream response so doRequest returns cleanly.
24+
w.Header().Set("Content-Type", "application/json")
25+
_, _ = w.Write([]byte(`{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`))
26+
}))
27+
defer srv.Close()
28+
29+
p := NewOpenAIProvider("kimi-coding-test", "sk-fake", srv.URL, "kimi-k2-turbo-preview").
30+
WithExtraHeaders(map[string]string{
31+
"User-Agent": "claude-code/0.1.0",
32+
"X-Trace-Id": "abc",
33+
})
34+
35+
body, err := p.doRequest(context.Background(), map[string]any{
36+
"model": "kimi-k2-turbo-preview",
37+
"messages": []map[string]string{{"role": "user", "content": "hi"}},
38+
})
39+
if err != nil {
40+
t.Fatalf("doRequest: %v", err)
41+
}
42+
_, _ = io.Copy(io.Discard, body)
43+
_ = body.Close()
44+
45+
if gotUserAgent != "claude-code/0.1.0" {
46+
t.Errorf("User-Agent = %q, want %q", gotUserAgent, "claude-code/0.1.0")
47+
}
48+
if gotXTrace != "abc" {
49+
t.Errorf("X-Trace-Id = %q, want %q", gotXTrace, "abc")
50+
}
51+
// Standard Bearer auth must still apply alongside extra headers.
52+
if gotAuth != "Bearer sk-fake" {
53+
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer sk-fake")
54+
}
55+
}
56+
57+
// TestOpenAIAdapter_ExtraHeaders_MirroredInToRequest verifies the adapter path
58+
// emits the same extra headers as the direct doRequest path — important
59+
// because some call sites use adapter.ToRequest to produce headers separately.
60+
func TestOpenAIAdapter_ExtraHeaders_MirroredInToRequest(t *testing.T) {
61+
p := NewOpenAIProvider("kimi-coding-test", "sk-fake", "https://api.kimi.com/coding/v1", "kimi-k2-turbo-preview").
62+
WithExtraHeaders(map[string]string{
63+
"User-Agent": "claude-code/0.1.0",
64+
})
65+
a := &OpenAIAdapter{provider: p}
66+
67+
_, headers, err := a.ToRequest(ChatRequest{
68+
Messages: []Message{{Role: "user", Content: "hi"}},
69+
})
70+
if err != nil {
71+
t.Fatalf("ToRequest: %v", err)
72+
}
73+
if got := headers.Get("User-Agent"); got != "claude-code/0.1.0" {
74+
t.Errorf("adapter User-Agent = %q, want claude-code/0.1.0", got)
75+
}
76+
}
77+
78+
// TestOpenAIProvider_ExtraHeaders_NoOpWhenEmpty makes sure the
79+
// WithExtraHeaders(nil) / WithExtraHeaders({}) calls leave the provider's
80+
// state alone — protects against accidental nil-map allocations in callers
81+
// that pass through optional config.
82+
func TestOpenAIProvider_ExtraHeaders_NoOpWhenEmpty(t *testing.T) {
83+
p := NewOpenAIProvider("x", "k", "https://example.com", "m").
84+
WithExtraHeaders(nil).
85+
WithExtraHeaders(map[string]string{})
86+
87+
if got := p.ExtraHeaders(); got != nil {
88+
t.Errorf("ExtraHeaders after empty calls = %v, want nil", got)
89+
}
90+
}
91+
92+
// TestKimiCoding_TemperatureSkipped reproduces the upstream rejection
93+
// `invalid temperature: only 1 is allowed for this model`. When the provider
94+
// is kimi_coding, the request body must omit temperature entirely so the
95+
// upstream applies its mandatory default.
96+
func TestKimiCoding_TemperatureSkipped(t *testing.T) {
97+
p := NewOpenAIProvider("kimi-coding", "sk-fake", "https://api.kimi.com/coding/v1", "kimi-k2-turbo-preview").
98+
WithProviderType("kimi_coding")
99+
100+
body := p.buildRequestBody("kimi-k2-turbo-preview", ChatRequest{
101+
Messages: []Message{{Role: "user", Content: "hi"}},
102+
Options: map[string]any{OptTemperature: 0.7},
103+
}, true)
104+
105+
if _, present := body["temperature"]; present {
106+
t.Errorf("temperature must not be sent to kimi_coding; got body[temperature]=%v", body["temperature"])
107+
}
108+
}
109+
110+
// TestKimiCoding_TemperatureSentForOtherProviders is the negative control —
111+
// without provider_type=kimi_coding, a temperature option still flows through.
112+
func TestKimiCoding_TemperatureSentForOtherProviders(t *testing.T) {
113+
p := NewOpenAIProvider("openai", "sk-fake", "https://api.openai.com/v1", "gpt-4o-mini")
114+
115+
body := p.buildRequestBody("gpt-4o-mini", ChatRequest{
116+
Messages: []Message{{Role: "user", Content: "hi"}},
117+
Options: map[string]any{OptTemperature: 0.7},
118+
}, true)
119+
120+
got, ok := body["temperature"]
121+
if !ok {
122+
t.Fatal("temperature must be sent for non-kimi providers")
123+
}
124+
if got != 0.7 {
125+
t.Errorf("temperature = %v, want 0.7", got)
126+
}
127+
}

internal/providers/openai_http.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser
4545
if p.siteTitle != "" {
4646
httpReq.Header.Set("X-Title", p.siteTitle)
4747
}
48+
// Static per-provider headers (e.g. fixed User-Agent for kimi_coding).
49+
// Applied after the standard headers so providers can override them if needed.
50+
for k, v := range p.extraHeaders {
51+
httpReq.Header.Set(k, v)
52+
}
4853

4954
resp, err := p.client.Do(httpReq)
5055
if err != nil {

internal/providers/openai_request.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
184184
// Note: gpt-5.X flagship models (gpt-5.1, gpt-5.4, gpt-5.5) DO support temperature;
185185
// only the mini/nano reasoning variants reject it.
186186
skipTemp := strings.HasPrefix(capabilityModel, "gpt-5-mini") || strings.HasPrefix(capabilityModel, "gpt-5-nano") || strings.HasPrefix(capabilityModel, "o1") || strings.HasPrefix(capabilityModel, "o3") || strings.HasPrefix(capabilityModel, "o4")
187+
// Kimi Coding rejects any temperature override — `invalid temperature: only
188+
// 1 is allowed for this model`. Skip sending so the upstream applies its
189+
// own default (1). Matches the model-locked behavior of o1/o3/o4.
190+
if p.providerType == "kimi_coding" {
191+
skipTemp = true
192+
}
187193
if !skipTemp {
188194
body["temperature"] = v
189195
}

internal/store/provider_store.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models)
3535
ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan
3636
ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC)
37+
ProviderKimiCoding = "kimi_coding" // Moonshot Kimi Coding (OpenAI-compat, requires fixed User-Agent)
3738

3839
// Novita AI defaults.
3940
NovitaDefaultAPIBase = "https://api.novita.ai/openai"
@@ -44,6 +45,12 @@ const (
4445
BytePlusCodingDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/coding/v3"
4546
BytePlusDefaultModel = "seed-2-0-lite-260228"
4647

48+
// Kimi Coding defaults. The upstream requires a fixed User-Agent on every
49+
// request — handled by the runtime in cmd/gateway_providers.go via
50+
// OpenAIProvider.WithExtraHeaders.
51+
KimiCodingDefaultAPIBase = "https://api.kimi.com/coding/v1"
52+
KimiCodingDefaultModel = "kimi-k2-turbo-preview"
53+
KimiCodingRequiredUserAgent = "claude-code/0.1.0"
4754
)
4855

4956
// Vertex AI constants live in internal/providers/vertex.go to avoid a store→providers import cycle
@@ -77,6 +84,7 @@ var ValidProviderTypes = map[string]bool{
7784
ProviderBytePlus: true,
7885
ProviderBytePlusCoding: true,
7986
ProviderVertex: true,
87+
ProviderKimiCoding: true,
8088
}
8189

8290
// VertexProviderSettings holds Vertex-specific config stored in llm_providers.settings JSONB.

ui/web/src/constants/providers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
3333
{ value: "zai_coding", label: "Z.ai Coding Plan", apiBase: "https://api.z.ai/api/coding/paas/v4", placeholder: "" },
3434
{ value: "byteplus", label: "BytePlus ModelArk", apiBase: "https://ark.ap-southeast.bytepluses.com/api/v3", placeholder: "" },
3535
{ value: "byteplus_coding", label: "BytePlus Coding Plan", apiBase: "https://ark.ap-southeast.bytepluses.com/api/coding/v3", placeholder: "" },
36+
{ value: "kimi_coding", label: "Kimi Coding (Moonshot)", apiBase: "https://api.kimi.com/coding/v1", placeholder: "" },
3637
{ value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" },
3738
{ value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" },
3839
{ value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" },

0 commit comments

Comments
 (0)