mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
Merge pull request #1172 from raihan0824/feat/provider-kimi-coding
Feat/provider kimi coding
This commit is contained in:
12 files changed
+394
-10
No files matched your search
@@ -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)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// ModelInfo is a normalized model entry returned by the list-models endpoint.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reasoning *providers.ReasoningCapability `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
@@ -109,11 +109,8 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
|
||||
models = minimaxModels()
|
||||
default:
|
||||
// All other types use OpenAI-compatible /models endpoint
|
||||
apiBase := strings.TrimRight(h.resolveAPIBase(p), "/")
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey)
|
||||
apiBase := openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p))
|
||||
models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey, openAIModelsExtraHeaders(p.ProviderType))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -126,6 +123,28 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
|
||||
respond(withReasoningCapabilities(models))
|
||||
}
|
||||
|
||||
func openAIModelsAPIBase(providerType, apiBase string) string {
|
||||
base := strings.TrimRight(apiBase, "/")
|
||||
if base != "" {
|
||||
return base
|
||||
}
|
||||
switch providerType {
|
||||
case store.ProviderKimiCoding:
|
||||
return store.KimiCodingDefaultAPIBase
|
||||
default:
|
||||
return "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
|
||||
func openAIModelsExtraHeaders(providerType string) map[string]string {
|
||||
if providerType != store.ProviderKimiCoding {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{
|
||||
"User-Agent": store.KimiCodingRequiredUserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func reasoningDefaultsForModels(
|
||||
settings []byte,
|
||||
models []ModelInfo,
|
||||
|
||||
@@ -91,12 +91,15 @@ func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error)
|
||||
}
|
||||
|
||||
// fetchOpenAIModels calls an OpenAI-compatible /models endpoint.
|
||||
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
|
||||
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string, extraHeaders map[string]string) ([]ModelInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -179,6 +179,73 @@ func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersHandlerListProviderModelsKimiCodingSendsRequiredUserAgent(t *testing.T) {
|
||||
token := setupProvidersAdminToken(t)
|
||||
var capturedAuth, capturedUserAgent string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
capturedUserAgent = r.Header.Get("User-Agent")
|
||||
if r.URL.Path != "/models" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": []map[string]string{
|
||||
{"id": store.KimiCodingDefaultModel},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
providerStore := newMockProviderStore()
|
||||
provider := &store.LLMProviderData{
|
||||
BaseModel: store.BaseModel{ID: uuid.New()},
|
||||
Name: "kimi-coding",
|
||||
ProviderType: store.ProviderKimiCoding,
|
||||
APIBase: upstream.URL,
|
||||
APIKey: "kimi-key",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := providerStore.CreateProvider(t.Context(), provider); err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
|
||||
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
if capturedAuth != "Bearer kimi-key" {
|
||||
t.Fatalf("Authorization = %q, want Bearer kimi-key", capturedAuth)
|
||||
}
|
||||
if capturedUserAgent != store.KimiCodingRequiredUserAgent {
|
||||
t.Fatalf("User-Agent = %q, want %q", capturedUserAgent, store.KimiCodingRequiredUserAgent)
|
||||
}
|
||||
var result ProviderModelsResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
if len(result.Models) != 1 || result.Models[0].ID != store.KimiCodingDefaultModel {
|
||||
t.Fatalf("models = %#v, want %q", result.Models, store.KimiCodingDefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIModelsAPIBaseDefaultsKimiCoding(t *testing.T) {
|
||||
if got := openAIModelsAPIBase(store.ProviderKimiCoding, ""); got != store.KimiCodingDefaultAPIBase {
|
||||
t.Fatalf("Kimi default api base = %q, want %q", got, store.KimiCodingDefaultAPIBase)
|
||||
}
|
||||
if got := openAIModelsAPIBase(store.ProviderOpenAICompat, ""); got != "https://api.openai.com/v1" {
|
||||
t.Fatalf("OpenAI compat default api base = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvidersHandlerListProviderModelsOllamaRichMetadata verifies that the
|
||||
// handler fetches /api/tags from Ollama and maps rich details (family,
|
||||
// parameter_size, quantization_level) into the display name.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKimiCoding_ReasoningContentAlwaysPresentOnAssistant reproduces upstream
|
||||
// "thinking is enabled but reasoning_content is missing in assistant tool call
|
||||
// message" — when an assistant message has tool_calls but no captured Thinking,
|
||||
// kimi_coding must still carry reasoning_content (empty string is fine).
|
||||
func TestKimiCoding_ReasoningContentAlwaysPresentOnAssistant(t *testing.T) {
|
||||
p := NewOpenAIProvider("kimi-coding", "sk", "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: "list pods"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "call_1", Name: "exec", Arguments: map[string]any{"cmd": "kubectl get pods"}}}},
|
||||
{Role: "tool", Content: "...", ToolCallID: "call_1"},
|
||||
},
|
||||
}, true)
|
||||
|
||||
msgs, ok := body["messages"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages not []map[string]any: %T", body["messages"])
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
||||
}
|
||||
// Assistant tool-call message must carry reasoning_content key.
|
||||
assistant := msgs[1]
|
||||
rc, present := assistant["reasoning_content"]
|
||||
if !present {
|
||||
t.Fatalf("kimi_coding assistant tool-call message must include reasoning_content key; got %v", assistant)
|
||||
}
|
||||
if rc != "" {
|
||||
t.Errorf("reasoning_content = %q, want empty string when Thinking unset", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKimiCoding_ReasoningContentPreservedWhenSet ensures the empty-string
|
||||
// fallback doesn't clobber real captured thinking content.
|
||||
// (Use a non-trailing assistant message — buildRequestBody strips trailing
|
||||
// assistant prefills as a safety net for proxy providers.)
|
||||
func TestKimiCoding_ReasoningContentPreservedWhenSet(t *testing.T) {
|
||||
p := NewOpenAIProvider("kimi-coding", "sk", "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"},
|
||||
{Role: "assistant", Content: "hello", Thinking: "the user said hi"},
|
||||
{Role: "user", Content: "more"},
|
||||
},
|
||||
}, true)
|
||||
|
||||
msgs := body["messages"].([]map[string]any)
|
||||
if got := msgs[1]["reasoning_content"]; got != "the user said hi" {
|
||||
t.Errorf("reasoning_content = %q, want %q", got, "the user said hi")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonKimi_ReasoningContentNotAddedWhenEmpty is the negative control — for
|
||||
// other providers in the allowlist (e.g. deepseek), an empty Thinking must NOT
|
||||
// inject an empty reasoning_content key, preserving today's behavior.
|
||||
func TestNonKimi_ReasoningContentNotAddedWhenEmpty(t *testing.T) {
|
||||
p := NewOpenAIProvider("deepseek", "sk", "https://api.deepseek.com/v1", "deepseek-chat")
|
||||
|
||||
body := p.buildRequestBody("deepseek-chat", ChatRequest{
|
||||
Messages: []Message{
|
||||
{Role: "user", Content: "hi"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "call_1", Name: "exec", Arguments: map[string]any{}}}},
|
||||
{Role: "tool", Content: "...", ToolCallID: "call_1"},
|
||||
},
|
||||
}, true)
|
||||
|
||||
msgs := body["messages"].([]map[string]any)
|
||||
if _, present := msgs[1]["reasoning_content"]; present {
|
||||
t.Error("non-kimi providers must not inject empty reasoning_content; key should be absent")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,8 +58,20 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
|
||||
|
||||
// Echo reasoning_content only for APIs/models that accept it on assistant history.
|
||||
// Together Qwen and many OpenAI-compat gateways reject unknown message fields → HTTP 400.
|
||||
if m.Thinking != "" && m.Role == "assistant" && openAIWireAssistantReasoningContent(model) {
|
||||
msg["reasoning_content"] = m.Thinking
|
||||
//
|
||||
// Kimi Coding is stricter: when its server-side thinking is on (always-on for
|
||||
// kimi-k2-turbo-preview), assistant tool-call messages MUST carry
|
||||
// reasoning_content even if empty — otherwise upstream returns 400 "thinking
|
||||
// is enabled but reasoning_content is missing in assistant tool call message".
|
||||
if m.Role == "assistant" && openAIWireAssistantReasoningContent(model) {
|
||||
switch {
|
||||
case m.Thinking != "":
|
||||
msg["reasoning_content"] = m.Thinking
|
||||
case p.providerType == "kimi_coding":
|
||||
// Send empty string rather than omit the field — satisfies Kimi's
|
||||
// "must be present" check without inventing reasoning content.
|
||||
msg["reasoning_content"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Include content; omit empty content for assistant messages with tool_calls
|
||||
@@ -204,6 +216,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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
Reference in new issue
Block a user