Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -2654,6 +2654,7 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
body = deepseekToolPatterns(p, to, body)
body = toolOneOfAsAnyOf(p, to, body)
body = kimiToolEnumTypes(p, to, body)
body = kimiCodeSampling(p, to, body)
body = clinePin(p, to, body)
if to == provider.Anthropic {
body = s.bodyBetas(p, body)
Expand Down
45 changes: 45 additions & 0 deletions internal/gateway/kimi_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,48 @@ func valuesType(vals []gjson.Result) string {
}
return t
}

// kimiCodeSampling is body without the sampling fields Kimi Code checks
// against a per-model, per-mode whitelist: kimi-for-coding takes only
// temperature 1 and top_p 0.95, a K2.5/K2.6 with thinking off takes only
// 0.6, and a client that sends its own defaults — VS Code Copilot's chat
// sends temperature 0.1 and top_p 1 — is refused 400 "invalid
// temperature: only 1 is allowed for this model" before any token is
// read, and the whole request fails. Left out, the server fills in the
// value the current model and mode want, so no number is written here.
// Moonshot's pay-as-you-go API, whose models take a range, keeps what the
// user set, as does a model that isn't a K and a request that never named
// the fields.
func kimiCodeSampling(p provider.Provider, to provider.Protocol, body []byte) []byte {
if !isKimiCode(p, to) {
return body
}
if !kimiKModel(gjson.GetBytes(body, "model").String()) {
return body
}
return withoutFields(body, "temperature", "top_p")
}

// isKimiCode is whether p is a Kimi Code membership's own endpoint, not
// Moonshot's pay-as-you-go API (api.moonshot.ai / api.moonshot.cn).
func isKimiCode(p provider.Provider, to provider.Protocol) bool {
if strings.HasPrefix(p.Preset, "kimi-code") {
return true
}
base := p.Base(to)
switch provider.HostOf(base) {
case "api.kimi.com", "api.kimi.ai":
return strings.Contains(base, "/coding")
}
return false
}

// kimiKModel is whether model is one of Kimi's K models: kimi-for-coding,
// kimi-k2.6, k3 and the like.
func kimiKModel(model string) bool {
m := strings.ToLower(model)
if strings.HasPrefix(m, "kimi") {
return true
}
return len(m) > 1 && m[0] == 'k' && m[1] >= '0' && m[1] <= '9'
}
97 changes: 97 additions & 0 deletions internal/gateway/kimi_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/provider"
)

Expand Down Expand Up @@ -72,3 +73,99 @@ func TestKimiToolEnumTypes(t *testing.T) {
t.Errorf("another provider was sent:\n%s", b)
}
}

// A request to a Kimi Code K model goes without temperature and top_p:
// the plan's whitelist refuses the ones clients send (Copilot's
// temperature 0.1, top_p 1, 400 "invalid temperature: only 1 is allowed
// for this model"), and left out the server fills in what the current
// model and mode take. A request that never named them stays without
// them; Moonshot's pay-as-you-go API, whose moonshot-v1 models take a
// range, keeps the user's own temperature, and so does anyone else.
func TestKimiCodeSampling(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
kimi, err := provider.FromPreset("kimi-code-cn")
if err != nil {
t.Fatal(err)
}
kimi.ID, kimi.Key, kimi.Models = "kimi", "k", []string{"kimi-for-coding"}
moonshot, err := provider.FromPreset("moonshot-cn")
if err != nil {
t.Fatal(err)
}
moonshot.ID, moonshot.Key, moonshot.Models = "moonshot", "k", []string{"moonshot-v1-8k"}
other := provider.Provider{ID: "other", Name: "Other", Key: "k", Chat: "https://other.test/v1", Models: []string{"m"}}
for _, p := range []provider.Provider{kimi, moonshot, other} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
type sentBody struct{ host, path, body string }
var sent []sentBody
s := New()
s.client = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) {
b, _ := io.ReadAll(r.Body)
sent = append(sent, sentBody{r.URL.Host, r.URL.Path, string(b)})
if r.URL.Path == "/v1/messages" {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))}, nil
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"c","object":"chat.completion","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))}, nil
})}
post := func(t *testing.T, path, body string) sentBody {
t.Helper()
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
return sent[len(sent)-1]
}
// the request VS Code Copilot's chat sends, refused 400 by Kimi Code
t.Run("refused values are dropped", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"kimi/kimi-for-coding","messages":[{"role":"user","content":"hi"}],"temperature":0.1,"top_p":1,"max_tokens":5}`)
if gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("Kimi Code was sent the sampling fields: %s", got.body)
}
if !json.Valid([]byte(got.body)) {
t.Errorf("Kimi Code was sent invalid JSON: %s", got.body)
}
})
t.Run("absent stays absent", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"kimi/kimi-for-coding","messages":[{"role":"user","content":"hi"}],"max_tokens":5}`)
if gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("sampling fields were added: %s", got.body)
}
})
t.Run("accepted values are dropped too", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"kimi/kimi-for-coding","messages":[{"role":"user","content":"hi"}],"temperature":1,"top_p":0.95}`)
if gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("Kimi Code was sent the sampling fields: %s", got.body)
}
})
t.Run("anthropic endpoint", func(t *testing.T) {
got := post(t, "/v1/messages", `{"model":"kimi/kimi-for-coding","messages":[{"role":"user","content":"hi"}],"temperature":0.1,"top_p":1,"max_tokens":5}`)
if got.host != "api.kimi.com" {
t.Fatalf("the request went to %s", got.host)
}
if gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("Kimi Code was sent the sampling fields: %s", got.body)
}
})
t.Run("moonshot pay-as-you-go keeps the user's temperature", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/moonshot-v1-8k","messages":[{"role":"user","content":"hi"}],"temperature":0.1,"top_p":1}`)
if got.host != "api.moonshot.cn" {
t.Fatalf("the request went to %s", got.host)
}
if v := gjson.Get(got.body, "temperature"); !v.Exists() || v.Raw != "0.1" {
t.Errorf("Moonshot's own temperature was not kept: %s", got.body)
}
})
t.Run("another provider is sent the body as it was", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"other/m","messages":[{"role":"user","content":"hi"}],"temperature":0.1,"top_p":1}`)
if !strings.Contains(got.body, `"temperature":0.1,"top_p":1`) {
t.Errorf("another provider was sent: %s", got.body)
}
})
}
Loading