1package provider
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
9 client := NewMoonshot("token", "").(*openaiClient)
10 tools := []Tool{
11 {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
12 {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
13 }
14 wire, err := client.buildRequest(Request{
15 Model: "kimi-k3",
16 Tools: tools,
17 Messages: []Message{
18 {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
19 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
20 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
21 },
22 })
23 if err != nil {
24 t.Fatal(err)
25 }
26 if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
27 t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
28 }
29 var loaded []oaiTool
30 for _, message := range wire.Messages {
31 if message.Role == "system" && len(message.Tools) > 0 {
32 loaded = message.Tools
33 }
34 }
35 if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
36 t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
37 }
38}
39
40// The deferred-tools handshake is a Moonshot API extension. Another
41// provider serving the same model id must get the standard wire instead:
42// no tools-bearing system message (it carries no content, which strict
43// schemas reject), and the activated tool in the top-level definitions
44// where every provider reads it.
45func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
46 client := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient)
47 tools := []Tool{
48 {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
49 {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
50 }
51 wire, err := client.buildRequest(Request{
52 Model: "kimi-k3",
53 Tools: tools,
54 Messages: []Message{
55 {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
56 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
57 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
58 },
59 })
60 if err != nil {
61 t.Fatal(err)
62 }
63 for _, message := range wire.Messages {
64 if len(message.Tools) > 0 {
65 t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
66 }
67 if message.Content == nil && len(message.ToolCalls) == 0 {
68 t.Fatalf("message %q has neither content nor tool calls", message.Role)
69 }
70 }
71 var names []string
72 for _, tool := range wire.Tools {
73 names = append(names, tool.Function.Name)
74 }
75 if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
76 t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
77 }
78}
79
80func TestMoonshotCNKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
81 client := NewMoonshotCN("token", "").(*openaiClient)
82 tools := []Tool{
83 {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
84 {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
85 }
86 wire, err := client.buildRequest(Request{
87 Model: "kimi-k3",
88 Tools: tools,
89 Messages: []Message{
90 {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
91 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
92 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
93 },
94 })
95 if err != nil {
96 t.Fatal(err)
97 }
98 if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
99 t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
100 }
101 var loaded []oaiTool
102 for _, message := range wire.Messages {
103 if message.Role == "system" && len(message.Tools) > 0 {
104 loaded = message.Tools
105 }
106 }
107 if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
108 t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
109 }
110}
111
112func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
113 tools := []Tool{
114 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
115 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
116 }
117 builders := map[string]func(Request) ([]string, error){
118 "openai": func(req Request) ([]string, error) {
119 wire, err := NewOpenAI("token", "").(*openaiClient).buildRequest(req)
120 if err != nil {
121 return nil, err
122 }
123 names := make([]string, 0, len(wire.Tools))
124 for _, tool := range wire.Tools {
125 names = append(names, tool.Function.Name)
126 }
127 return names, nil
128 },
129 "anthropic": func(req Request) ([]string, error) {
130 req.Model = "claude-sonnet-4-6"
131 wire, err := NewAnthropic("token", "").(*anthropicClient).buildRequest(req)
132 if err != nil {
133 return nil, err
134 }
135 names := make([]string, 0, len(wire.Tools))
136 for _, tool := range wire.Tools {
137 names = append(names, tool.Name)
138 }
139 return names, nil
140 },
141 "bedrock": func(req Request) ([]string, error) {
142 req.Model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
143 wire, err := (&bedrockClient{region: "us-east-1"}).buildRequest(req)
144 if err != nil {
145 return nil, err
146 }
147 if wire.ToolConfig == nil {
148 return nil, nil
149 }
150 names := make([]string, 0, len(wire.ToolConfig.Tools))
151 for _, tool := range wire.ToolConfig.Tools {
152 names = append(names, tool.ToolSpec.Name)
153 }
154 return names, nil
155 },
156 "gemini": func(req Request) ([]string, error) {
157 req.Model = "gemini-2.5-pro"
158 wire, _, err := NewGemini("token", "").(*geminiClient).buildRequest(req)
159 if err != nil {
160 return nil, err
161 }
162 var names []string
163 for _, group := range wire.Tools {
164 for _, tool := range group.FunctionDeclarations {
165 names = append(names, tool.Name)
166 }
167 }
168 return names, nil
169 },
170 "openai-codex": func(req Request) ([]string, error) {
171 req.Model = "gpt-5.5"
172 wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req)
173 if err != nil {
174 return nil, err
175 }
176 names := make([]string, 0, len(wire.Tools))
177 for _, tool := range wire.Tools {
178 names = append(names, tool.Name)
179 }
180 return names, nil
181 },
182 }
183
184 for name, build := range builders {
185 t.Run(name, func(t *testing.T) {
186 initial, err := build(Request{Model: "gpt-5", Tools: tools})
187 if err != nil {
188 t.Fatal(err)
189 }
190 if len(initial) != 1 || initial[0] != "base" {
191 t.Fatalf("initial tools = %v, want [base]", initial)
192 }
193
194 activated, err := build(Request{
195 Model: "gpt-5",
196 Tools: tools,
197 Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
198 })
199 if err != nil {
200 t.Fatal(err)
201 }
202 if len(activated) != 2 || activated[0] != "base" || activated[1] != "late" {
203 t.Fatalf("activated tools = %v, want [base late]", activated)
204 }
205 })
206 }
207}
208
209func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
210 for _, tc := range []struct {
211 provider string
212 model string
213 }{
214 {"kimi", "k3"},
215 {"moonshotai", "kimi-k3"},
216 {"moonshotai-cn", "kimi-k3"},
217 {"openrouter", "moonshotai/kimi-k3"},
218 {"vercel-ai-gateway", "moonshotai/kimi-k3"},
219 } {
220 m, err := FindModel(tc.provider, tc.model)
221 if err != nil {
222 t.Fatalf("%s/%s: %v", tc.provider, tc.model, err)
223 }
224 if m.MaxOutput != 131072 || !m.Reasoning {
225 t.Fatalf("%s/%s metadata = %+v", tc.provider, tc.model, m)
226 }
227 }
228 m, err := FindModel("xai", "grok-4.5")
229 if err != nil {
230 t.Fatal(err)
231 }
232 if !m.Reasoning || m.MaxOutput != 30000 || m.API != APIResponses {
233 t.Fatalf("xai/grok-4.5 metadata = %+v", m)
234 }
235}
236
237func TestMoonshotDeferredMarkerSurvivesDedupOnRetainedMessage(t *testing.T) {
238 client := NewMoonshot("token", "").(*openaiClient)
239 tools := []Tool{
240 {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
241 {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
242 }
243 wire, err := client.buildRequest(Request{
244 Model: "kimi-k3",
245 Tools: tools,
246 Messages: []Message{
247 {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
248 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
249 // Duplicate results: repair keeps the first and its marker, so
250 // the deferred tool still loads on the handshake message.
251 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
252 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "stale duplicate"}}}}, AddedToolNames: []string{"lookup_weather"}},
253 },
254 })
255 if err != nil {
256 t.Fatal(err)
257 }
258 var loaded []oaiTool
259 for _, message := range wire.Messages {
260 if message.Role == "system" && len(message.Tools) > 0 {
261 loaded = message.Tools
262 }
263 }
264 if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
265 t.Fatalf("loaded tools = %+v, want lookup_weather from the retained marker", loaded)
266 }
267}