detached 27cf8b3

Merge pull request #144 from Codonyat/gate-deferred-tools-to-moonshot

Gate the Kimi deferred-tools handshake to Moonshot providers
commit
27cf8b3edd2c9bc8b8506e778c0a1ea61a89c079
author
Patric Eckhart · · 2026-08-16 11:40 UTC
committer
GitHub · · 2026-08-16 11:40 UTC
merge of
e900970 + 8c2eb47 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.44
2 files · +55 -1
M packages/provider/deferred_tools_test.go +39 -0
@@ -37,6 +37,45 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3737 }
3838 }
3939
40+// The deferred-tools handshake is a Moonshot API extension. Another provider
41+// serving the same model id must get the standard wire instead: no tools-bearing
42+// system message (it carries no content, which strict schemas reject), and the
43+// activated tool in the top-level definitions where every provider reads it.
44+func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
45+ client := NewGondola("token", "").(*openaiClient)
46+ tools := []Tool{
47+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
48+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
49+ }
50+ wire, err := client.buildRequest(Request{
51+ Model: "kimi-k3",
52+ Tools: tools,
53+ Messages: []Message{
54+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
55+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
56+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ for _, message := range wire.Messages {
63+ if len(message.Tools) > 0 {
64+ t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
65+ }
66+ if message.Content == nil && len(message.ToolCalls) == 0 {
67+ t.Fatalf("message %q has neither content nor tool calls", message.Role)
68+ }
69+ }
70+ var names []string
71+ for _, tool := range wire.Tools {
72+ names = append(names, tool.Function.Name)
73+ }
74+ if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
75+ t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
76+ }
77+}
78+
4079 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
4180 tools := []Tool{
4281 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
@@ -37,6 +37,45 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
37 }37 }
38 }38 }
39 39
40+// The deferred-tools handshake is a Moonshot API extension. Another provider
41+// serving the same model id must get the standard wire instead: no tools-bearing
42+// system message (it carries no content, which strict schemas reject), and the
43+// activated tool in the top-level definitions where every provider reads it.
44+func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
45+ client := NewGondola("token", "").(*openaiClient)
46+ tools := []Tool{
47+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
48+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
49+ }
50+ wire, err := client.buildRequest(Request{
51+ Model: "kimi-k3",
52+ Tools: tools,
53+ Messages: []Message{
54+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
55+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
56+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ for _, message := range wire.Messages {
63+ if len(message.Tools) > 0 {
64+ t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
65+ }
66+ if message.Content == nil && len(message.ToolCalls) == 0 {
67+ t.Fatalf("message %q has neither content nor tool calls", message.Role)
68+ }
69+ }
70+ var names []string
71+ for _, tool := range wire.Tools {
72+ names = append(names, tool.Function.Name)
73+ }
74+ if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
75+ t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
76+ }
77+}
78+
40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {79 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41 tools := []Tool{80 tools := []Tool{
42 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},81 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
M packages/provider/openai.go +16 -1
@@ -276,7 +276,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
276276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
277277 }
278278
279- deferredMode := isKimiDeferredModel(req.Model)
279+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
280280 toolByName := make(map[string]Tool, len(req.Tools))
281281 for _, t := range req.Tools {
282282 toolByName[t.Name] = t
@@ -392,6 +392,21 @@ func isKimiDeferredModel(model string) bool {
392392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
393393 }
394394
395+// supportsDeferredTools reports whether a provider speaks Moonshot's
396+// deferred-tools handshake, where a tool that becomes available mid-conversation
397+// is delivered on a system message carrying a `tools` array.
398+//
399+// That message is a Moonshot API extension, not OpenAI wire format: it has no
400+// content, which a strict chat-completions schema rejects outright. Gondola,
401+// which serves kimi-k3 on top of Venice, answers "messages.N: Invalid input"
402+// and the conversation cannot recover, because the injection is rebuilt from
403+// message history on every later turn. Providers outside this list take the
404+// standard path instead, where an activated deferred tool joins the top-level
405+// tools array.
406+func supportsDeferredTools(provider string) bool {
407+ return provider == "moonshotai" || provider == "moonshotai-cn"
408+}
409+
395410 func makeOAITool(t Tool) oaiTool {
396411 var tool oaiTool
397412 tool.Type = "function"
@@ -276,7 +276,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
277 }277 }
278 278
279- deferredMode := isKimiDeferredModel(req.Model)279+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
280 toolByName := make(map[string]Tool, len(req.Tools))280 toolByName := make(map[string]Tool, len(req.Tools))
281 for _, t := range req.Tools {281 for _, t := range req.Tools {
282 toolByName[t.Name] = t282 toolByName[t.Name] = t
@@ -392,6 +392,21 @@ func isKimiDeferredModel(model string) bool {
392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
393 }393 }
394 394
395+// supportsDeferredTools reports whether a provider speaks Moonshot's
396+// deferred-tools handshake, where a tool that becomes available mid-conversation
397+// is delivered on a system message carrying a `tools` array.
398+//
399+// That message is a Moonshot API extension, not OpenAI wire format: it has no
400+// content, which a strict chat-completions schema rejects outright. Gondola,
401+// which serves kimi-k3 on top of Venice, answers "messages.N: Invalid input"
402+// and the conversation cannot recover, because the injection is rebuilt from
403+// message history on every later turn. Providers outside this list take the
404+// standard path instead, where an activated deferred tool joins the top-level
405+// tools array.
406+func supportsDeferredTools(provider string) bool {
407+ return provider == "moonshotai" || provider == "moonshotai-cn"
408+}
409+
395 func makeOAITool(t Tool) oaiTool {410 func makeOAITool(t Tool) oaiTool {
396 var tool oaiTool411 var tool oaiTool
397 tool.Type = "function"412 tool.Type = "function"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close