diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index ba19ff98..983317b5 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -440,6 +440,19 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel) prov.WithProviderType(p.ProviderType) registry.RegisterForTenant(p.TenantID, prov) + case store.ProviderKimiCoding: + // Moonshot Kimi Coding requires a fixed User-Agent on every request. + // OpenAI-compatible wire shape otherwise. + base := p.APIBase + if base == "" { + base = store.KimiCodingDefaultAPIBase + } + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel) + prov.WithProviderType(p.ProviderType) + prov.WithExtraHeaders(map[string]string{ + "User-Agent": store.KimiCodingRequiredUserAgent, + }) + registry.RegisterForTenant(p.TenantID, prov) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") prov.WithProviderType(p.ProviderType) diff --git a/internal/http/providers.go b/internal/http/providers.go index 6bb28bab..ae4a6b8f 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -298,6 +298,18 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu base = store.NovitaDefaultAPIBase } h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel)) + case store.ProviderKimiCoding: + // Moonshot Kimi Coding requires a fixed User-Agent on every request. + base := apiBase + if base == "" { + base = store.KimiCodingDefaultAPIBase + } + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel) + prov.WithProviderType(p.ProviderType) + prov.WithExtraHeaders(map[string]string{ + "User-Agent": store.KimiCodingRequiredUserAgent, + }) + h.providerReg.RegisterForTenant(p.TenantID, prov) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "") if p.ProviderType == store.ProviderMiniMax { diff --git a/internal/providers/adapter_openai.go b/internal/providers/adapter_openai.go index 599f58b3..6e744441 100644 --- a/internal/providers/adapter_openai.go +++ b/internal/providers/adapter_openai.go @@ -61,6 +61,10 @@ func (a *OpenAIAdapter) ToRequest(req ChatRequest) ([]byte, http.Header, error) if a.provider.siteTitle != "" { h.Set("X-Title", a.provider.siteTitle) } + // Mirror doRequest: provider-static headers (e.g. kimi_coding User-Agent). + for k, v := range a.provider.extraHeaders { + h.Set(k, v) + } return data, h, nil } diff --git a/internal/providers/openai_config.go b/internal/providers/openai_config.go index c8e10c58..e6846b97 100644 --- a/internal/providers/openai_config.go +++ b/internal/providers/openai_config.go @@ -17,6 +17,7 @@ type OpenAIProvider struct { providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native") siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer) siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title) + extraHeaders map[string]string // static headers set on every outgoing request (e.g. fixed User-Agent for kimi_coding) client *http.Client retryConfig RetryConfig middlewares RequestMiddleware // composed middleware chain (nil = no-op) @@ -63,6 +64,36 @@ func (p *OpenAIProvider) WithSiteInfo(url, title string) *OpenAIProvider { return p } +// WithExtraHeaders sets static headers attached to every outgoing request. +// Used by providers that require a fixed identity header (e.g. kimi_coding's +// User-Agent: claude-code/0.1.0). Repeat calls merge — keys already present are +// overwritten. Passing an empty map is a no-op. +func (p *OpenAIProvider) WithExtraHeaders(h map[string]string) *OpenAIProvider { + if len(h) == 0 { + return p + } + if p.extraHeaders == nil { + p.extraHeaders = make(map[string]string, len(h)) + } + for k, v := range h { + p.extraHeaders[k] = v + } + return p +} + +// ExtraHeaders returns a copy of the static headers configured for this provider. +// Used by adapter_openai.go to mirror the runtime request headers. +func (p *OpenAIProvider) ExtraHeaders() map[string]string { + if len(p.extraHeaders) == 0 { + return nil + } + out := make(map[string]string, len(p.extraHeaders)) + for k, v := range p.extraHeaders { + out[k] = v + } + return out +} + // WithRegistry sets the model registry for forward-compat resolution. func (p *OpenAIProvider) WithRegistry(r ModelRegistry) *OpenAIProvider { p.registry = r diff --git a/internal/providers/openai_extra_headers_test.go b/internal/providers/openai_extra_headers_test.go new file mode 100644 index 00000000..68ef5205 --- /dev/null +++ b/internal/providers/openai_extra_headers_test.go @@ -0,0 +1,127 @@ +package providers + +// Coverage for OpenAIProvider.WithExtraHeaders — the mechanism Kimi Coding +// uses to send a fixed User-Agent on every request. + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// TestOpenAIProvider_ExtraHeaders_AppliedOnHTTPRequest verifies that headers +// set via WithExtraHeaders reach the actual outgoing request — not just the +// adapter's header map. +func TestOpenAIProvider_ExtraHeaders_AppliedOnHTTPRequest(t *testing.T) { + var gotUserAgent, gotXTrace, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + gotXTrace = r.Header.Get("X-Trace-Id") + gotAuth = r.Header.Get("Authorization") + // Minimal non-stream response so doRequest returns cleanly. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`)) + })) + defer srv.Close() + + p := NewOpenAIProvider("kimi-coding-test", "sk-fake", srv.URL, "kimi-k2-turbo-preview"). + WithExtraHeaders(map[string]string{ + "User-Agent": "claude-code/0.1.0", + "X-Trace-Id": "abc", + }) + + body, err := p.doRequest(context.Background(), map[string]any{ + "model": "kimi-k2-turbo-preview", + "messages": []map[string]string{{"role": "user", "content": "hi"}}, + }) + if err != nil { + t.Fatalf("doRequest: %v", err) + } + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + + if gotUserAgent != "claude-code/0.1.0" { + t.Errorf("User-Agent = %q, want %q", gotUserAgent, "claude-code/0.1.0") + } + if gotXTrace != "abc" { + t.Errorf("X-Trace-Id = %q, want %q", gotXTrace, "abc") + } + // Standard Bearer auth must still apply alongside extra headers. + if gotAuth != "Bearer sk-fake" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer sk-fake") + } +} + +// TestOpenAIAdapter_ExtraHeaders_MirroredInToRequest verifies the adapter path +// emits the same extra headers as the direct doRequest path — important +// because some call sites use adapter.ToRequest to produce headers separately. +func TestOpenAIAdapter_ExtraHeaders_MirroredInToRequest(t *testing.T) { + p := NewOpenAIProvider("kimi-coding-test", "sk-fake", "https://api.kimi.com/coding/v1", "kimi-k2-turbo-preview"). + WithExtraHeaders(map[string]string{ + "User-Agent": "claude-code/0.1.0", + }) + a := &OpenAIAdapter{provider: p} + + _, headers, err := a.ToRequest(ChatRequest{ + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("ToRequest: %v", err) + } + if got := headers.Get("User-Agent"); got != "claude-code/0.1.0" { + t.Errorf("adapter User-Agent = %q, want claude-code/0.1.0", got) + } +} + +// TestOpenAIProvider_ExtraHeaders_NoOpWhenEmpty makes sure the +// WithExtraHeaders(nil) / WithExtraHeaders({}) calls leave the provider's +// state alone — protects against accidental nil-map allocations in callers +// that pass through optional config. +func TestOpenAIProvider_ExtraHeaders_NoOpWhenEmpty(t *testing.T) { + p := NewOpenAIProvider("x", "k", "https://example.com", "m"). + WithExtraHeaders(nil). + WithExtraHeaders(map[string]string{}) + + if got := p.ExtraHeaders(); got != nil { + t.Errorf("ExtraHeaders after empty calls = %v, want nil", got) + } +} + +// TestKimiCoding_TemperatureSkipped reproduces the upstream rejection +// `invalid temperature: only 1 is allowed for this model`. When the provider +// is kimi_coding, the request body must omit temperature entirely so the +// upstream applies its mandatory default. +func TestKimiCoding_TemperatureSkipped(t *testing.T) { + p := NewOpenAIProvider("kimi-coding", "sk-fake", "https://api.kimi.com/coding/v1", "kimi-k2-turbo-preview"). + WithProviderType("kimi_coding") + + body := p.buildRequestBody("kimi-k2-turbo-preview", ChatRequest{ + Messages: []Message{{Role: "user", Content: "hi"}}, + Options: map[string]any{OptTemperature: 0.7}, + }, true) + + if _, present := body["temperature"]; present { + t.Errorf("temperature must not be sent to kimi_coding; got body[temperature]=%v", body["temperature"]) + } +} + +// TestKimiCoding_TemperatureSentForOtherProviders is the negative control — +// without provider_type=kimi_coding, a temperature option still flows through. +func TestKimiCoding_TemperatureSentForOtherProviders(t *testing.T) { + p := NewOpenAIProvider("openai", "sk-fake", "https://api.openai.com/v1", "gpt-4o-mini") + + body := p.buildRequestBody("gpt-4o-mini", ChatRequest{ + Messages: []Message{{Role: "user", Content: "hi"}}, + Options: map[string]any{OptTemperature: 0.7}, + }, true) + + got, ok := body["temperature"] + if !ok { + t.Fatal("temperature must be sent for non-kimi providers") + } + if got != 0.7 { + t.Errorf("temperature = %v, want 0.7", got) + } +} diff --git a/internal/providers/openai_http.go b/internal/providers/openai_http.go index 99b269a8..4b5de5fc 100644 --- a/internal/providers/openai_http.go +++ b/internal/providers/openai_http.go @@ -45,6 +45,11 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser if p.siteTitle != "" { httpReq.Header.Set("X-Title", p.siteTitle) } + // Static per-provider headers (e.g. fixed User-Agent for kimi_coding). + // Applied after the standard headers so providers can override them if needed. + for k, v := range p.extraHeaders { + httpReq.Header.Set(k, v) + } resp, err := p.client.Do(httpReq) if err != nil { diff --git a/internal/providers/openai_request.go b/internal/providers/openai_request.go index 401d6dcc..a2757d21 100644 --- a/internal/providers/openai_request.go +++ b/internal/providers/openai_request.go @@ -188,6 +188,12 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream // Note: gpt-5.X flagship models (gpt-5.1, gpt-5.4, gpt-5.5) DO support temperature; // only the mini/nano reasoning variants reject it. 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") + // Kimi Coding rejects any temperature override — `invalid temperature: only + // 1 is allowed for this model`. Skip sending so the upstream applies its + // own default (1). Matches the model-locked behavior of o1/o3/o4. + if p.providerType == "kimi_coding" { + skipTemp = true + } if !skipTemp { body["temperature"] = v } diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 8b81d563..1a0d08a5 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -34,6 +34,7 @@ const ( ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models) ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC) + ProviderKimiCoding = "kimi_coding" // Moonshot Kimi Coding (OpenAI-compat, requires fixed User-Agent) // Novita AI defaults. NovitaDefaultAPIBase = "https://api.novita.ai/openai" @@ -44,6 +45,12 @@ const ( BytePlusCodingDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/coding/v3" BytePlusDefaultModel = "seed-2-0-lite-260228" + // Kimi Coding defaults. The upstream requires a fixed User-Agent on every + // request — handled by the runtime in cmd/gateway_providers.go via + // OpenAIProvider.WithExtraHeaders. + KimiCodingDefaultAPIBase = "https://api.kimi.com/coding/v1" + KimiCodingDefaultModel = "kimi-k2-turbo-preview" + KimiCodingRequiredUserAgent = "claude-code/0.1.0" ) // 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{ ProviderBytePlus: true, ProviderBytePlusCoding: true, ProviderVertex: true, + ProviderKimiCoding: true, } // VertexProviderSettings holds Vertex-specific config stored in llm_providers.settings JSONB. diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 637e19f1..d82aba46 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -33,6 +33,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "zai_coding", label: "Z.ai Coding Plan", apiBase: "https://api.z.ai/api/coding/paas/v4", placeholder: "" }, { value: "byteplus", label: "BytePlus ModelArk", apiBase: "https://ark.ap-southeast.bytepluses.com/api/v3", placeholder: "" }, { value: "byteplus_coding", label: "BytePlus Coding Plan", apiBase: "https://ark.ap-southeast.bytepluses.com/api/coding/v3", placeholder: "" }, + { value: "kimi_coding", label: "Kimi Coding (Moonshot)", apiBase: "https://api.kimi.com/coding/v1", placeholder: "" }, { value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" }, { value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" }, { value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" },