Gate the Kimi deferred-tools handshake to Moonshot providers
The deferred-tools path delivers a mid-conversation tool definition on a system message carrying a `tools` array and no content. That is a Moonshot API extension, but `deferredMode` was keyed on the model id alone, so every OpenAI-compatible provider serving a model named `kimi-k3` received it. Gondola, whose built-in provider defaults to `kimi-k3`, answers `messages.N: Invalid input`: its upstream requires content on a system message. The turn cannot recover either, because `AddedToolNames` is persisted on the message and survives history rebuilds, compaction and session resume, so the injection is re-emitted on every later request in that session. Adding content to the injected message is not a fix. The upstream accepts a message-level `tools` key and ignores it, so the loaded tool would silently never become callable. Gating on the provider instead routes everyone else through the standard path, where an activated deferred tool joins the top-level tools array. Verified against Gondola with an extension registering a deferred tool: before, the turn after activation fails with the 400 above; after, the agent loads the tool, calls it, and completes the loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- commit
- 8c2eb47593f1fd4eadff61935bba5c08ec5be21f
- author
- Codonyat · · 2026-08-14 20:36 UTC
- parent
- e900970
- signature
- unsigned
2 files · +55 -1
M packages/provider/deferred_tools_test.go +39 -0
| @@ -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 | 79 | func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) { | |
| 41 | 80 | tools := []Tool{ | |
| 42 | 81 | {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) { | |||
| 276 | 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 | 280 | toolByName := make(map[string]Tool, len(req.Tools)) | |
| 281 | 281 | for _, t := range req.Tools { | |
| 282 | 282 | toolByName[t.Name] = t | |
| @@ -392,6 +392,21 @@ func isKimiDeferredModel(model string) bool { | |||
| 392 | 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 | 410 | func makeOAITool(t Tool) oaiTool { | |
| 396 | 411 | var tool oaiTool | |
| 397 | 412 | 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] = t | 282 | 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 oaiTool | 411 | var tool oaiTool | ||
| 397 | tool.Type = "function" | 412 | tool.Type = "function" | ||