Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 = kimiLockedSampling(p, to, body)
body = clinePin(p, to, body)
if to == provider.Anthropic {
body = s.bodyBetas(p, body)
Expand Down
72 changes: 72 additions & 0 deletions internal/gateway/kimi_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gateway

import (
"bytes"
"strconv"
"strings"

"github.com/tidwall/gjson"
Expand Down Expand Up @@ -138,3 +139,74 @@ func valuesType(vals []gjson.Result) string {
}
return t
}

// kimiLockedSampling is body without temperature and top_p when the model's
// server locks them to one value: since kimi-k2.5 every kimi-kX.Y on both
// Kimi platforms (Moonshot's API and a Kimi Code membership's), and Kimi
// Code's own kimi-for-coding and k3*. 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 (a K2.5/K2.6 with thinking off takes only 0.6),
// and the whole request fails. Left out, the server fills in the value the
// model and mode want, so no number is written here. Models that take a
// range — moonshot-v1-*, kimi-k2-0905-preview, kimi-k2-thinking,
// kimi-latest — keep what the user set, as does anyone not served by
// Moonshot.
func kimiLockedSampling(p provider.Provider, to provider.Protocol, body []byte) []byte {
if !isKimi(p, to) {
return body
}
if !kimiLockedSamplingModel(gjson.GetBytes(body, "model").String()) {
return body
}
return withoutFields(body, "temperature", "top_p")
}

// kimiLockedSamplingModel is whether the model's server locks temperature
// and top_p: kimi-for-coding* and k3* on Kimi Code, and kimi-k2.5 and
// later (kimi-k2.6, kimi-k3, …) on either platform. A leading "vendor/" is
// ignored.
func kimiLockedSamplingModel(model string) bool {
m := strings.ToLower(model)
if i := strings.LastIndexByte(m, '/'); i >= 0 {
m = m[i+1:]
}
if strings.HasPrefix(m, "kimi-for-coding") {
return true
}
v, ok := strings.CutPrefix(m, "kimi-k")
if !ok {
if v, ok = strings.CutPrefix(m, "k"); !ok {
return false
}
}
major, rest := leadingInt(v)
if major < 0 {
return false
}
if major != 2 {
return major >= 3
}
if !strings.HasPrefix(rest, ".") {
return false // kimi-k2-thinking, kimi-k2-0905-preview
}
minor, _ := leadingInt(rest[1:])
return minor >= 5
}

// leadingInt reads the decimal integer s begins with, -1 when there is
// none, and what follows it.
func leadingInt(s string) (int, string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 {
return -1, s
}
n, err := strconv.Atoi(s[:i])
if err != nil {
return -1, s
}
return n, s[i:]
}
156 changes: 156 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,158 @@ func TestKimiToolEnumTypes(t *testing.T) {
t.Errorf("another provider was sent:\n%s", b)
}
}

// A request to a model whose server locks temperature and top_p — Kimi
// Code's kimi-for-coding and k3*, kimi-k2.5 and later on either Kimi
// platform — goes without them: the 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; a model that takes a range (moonshot-v1-8k,
// kimi-k2-0905-preview, kimi-k2-thinking) keeps the user's own
// temperature; a relay's vendor-prefixed name (moonshotai/kimi-k2.6)
// still names the locked model; and anyone else is sent the body as it
// was.
func TestKimiLockedSampling(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", "k3"}
moonshot, err := provider.FromPreset("moonshot-cn")
if err != nil {
t.Fatal(err)
}
moonshot.ID, moonshot.Key = "moonshot", "k"
moonshot.Models = []string{"moonshot-v1-8k", "kimi-k2.5", "kimi-k2.6", "kimi-k2-0905-preview", "kimi-k2-thinking", "moonshotai/kimi-k2.6"}
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("kimi code's k3 drops the fields", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"kimi/k3","messages":[{"role":"user","content":"hi"}],"temperature":0.1,"top_p":1}`)
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("k3 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 kimi-k2.6 drops the fields", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/kimi-k2.6","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 gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("kimi-k2.6 was sent the sampling fields: %s", got.body)
}
})
t.Run("moonshot kimi-k2.5, the first locked version, drops the fields", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/kimi-k2.5","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 gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("kimi-k2.5 was sent the sampling fields: %s", got.body)
}
})
t.Run("a relay's vendor-prefixed name still names the locked model", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/moonshotai/kimi-k2.6","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 gjson.Get(got.body, "temperature").Exists() || gjson.Get(got.body, "top_p").Exists() {
t.Errorf("moonshotai/kimi-k2.6 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("moonshot kimi-k2-0905-preview keeps the user's temperature", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/kimi-k2-0905-preview","messages":[{"role":"user","content":"hi"}],"temperature":0.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("kimi-k2-0905-preview's temperature was not kept: %s", got.body)
}
})
t.Run("moonshot kimi-k2-thinking keeps the user's temperature", func(t *testing.T) {
got := post(t, "/v1/chat/completions", `{"model":"moonshot/kimi-k2-thinking","messages":[{"role":"user","content":"hi"}],"temperature":0.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("kimi-k2-thinking's 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