Merge pull request #144 from Codonyat/gate-deferred-tools-to-moonshot
Gate the Kimi deferred-tools handshake to Moonshot providers
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" | ||