Skip to content

Commit 3acf4cc

Browse files
Fix: function calling (#121)
* Implement tool-calling support across providers and adapters * Deduplicate responses function-call ID helpers * Fix tool-calling compatibility follow-up issues * Fix responses replay and streamed tool-call metadata * Tighten anthropic request validation and swagger docs * Fix Anthropic tool validation edge cases * Preserve null tool-call content across adapters * Tighten tool-calling adapter regressions * Fix Anthropic tool argument normalization * Harden responses streaming tool-call replay * Address PR #121 review suggestions - Make ContentNull defensive: only emit null when Content is empty - Replace anonymous IIFE role mapping with simple if/else in Anthropic - Export and deduplicate BuildResponsesOutputItems across providers - Rename parseResponsesConverterTestEvents to parseTestSSEEvents for consistency - Replace sort.Ints with slices.Sort Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor tool-calling request and stream handling --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8149ab0 commit 3acf4cc

32 files changed

Lines changed: 4419 additions & 313 deletions

GETTING_STARTED.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,39 @@ curl http://localhost:8080/v1/chat/completions \
254254
}'
255255
```
256256

257+
#### Chat Completion with Function Calling
258+
259+
```bash
260+
curl http://localhost:8080/v1/chat/completions \
261+
-H "Content-Type: application/json" \
262+
-d '{
263+
"model": "gpt-4o-mini",
264+
"messages": [
265+
{"role": "user", "content": "What is the weather in Warsaw?"}
266+
],
267+
"tools": [
268+
{
269+
"type": "function",
270+
"function": {
271+
"name": "lookup_weather",
272+
"description": "Get the weather for a city.",
273+
"parameters": {
274+
"type": "object",
275+
"properties": {
276+
"city": {"type": "string"}
277+
},
278+
"required": ["city"]
279+
}
280+
}
281+
}
282+
],
283+
"tool_choice": {
284+
"type": "function",
285+
"function": {"name": "lookup_weather"}
286+
}
287+
}'
288+
```
289+
257290
#### Streaming Response
258291

259292
```bash

cmd/gomodel/docs/docs.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,9 @@ const docTemplate = `{
17791779
"model": {
17801780
"type": "string"
17811781
},
1782+
"parallel_tool_calls": {
1783+
"type": "boolean"
1784+
},
17821785
"provider": {
17831786
"type": "string"
17841787
},
@@ -1793,6 +1796,16 @@ const docTemplate = `{
17931796
},
17941797
"temperature": {
17951798
"type": "number"
1799+
},
1800+
"tool_choice": {
1801+
"description": "string or object"
1802+
},
1803+
"tools": {
1804+
"type": "array",
1805+
"items": {
1806+
"type": "object",
1807+
"additionalProperties": {}
1808+
}
17961809
}
17971810
}
17981811
},
@@ -2040,6 +2053,9 @@ const docTemplate = `{
20402053
"role": {
20412054
"type": "string"
20422055
},
2056+
"tool_call_id": {
2057+
"type": "string"
2058+
},
20432059
"tool_calls": {
20442060
"type": "array",
20452061
"items": {
@@ -2289,6 +2305,12 @@ const docTemplate = `{
22892305
"core.ResponsesOutputItem": {
22902306
"type": "object",
22912307
"properties": {
2308+
"arguments": {
2309+
"type": "string"
2310+
},
2311+
"call_id": {
2312+
"type": "string"
2313+
},
22922314
"content": {
22932315
"type": "array",
22942316
"items": {
@@ -2298,6 +2320,9 @@ const docTemplate = `{
22982320
"id": {
22992321
"type": "string"
23002322
},
2323+
"name": {
2324+
"type": "string"
2325+
},
23012326
"role": {
23022327
"type": "string"
23032328
},
@@ -2333,6 +2358,9 @@ const docTemplate = `{
23332358
"model": {
23342359
"type": "string"
23352360
},
2361+
"parallel_tool_calls": {
2362+
"type": "boolean"
2363+
},
23362364
"provider": {
23372365
"type": "string"
23382366
},
@@ -2348,6 +2376,9 @@ const docTemplate = `{
23482376
"temperature": {
23492377
"type": "number"
23502378
},
2379+
"tool_choice": {
2380+
"description": "string or object"
2381+
},
23512382
"tools": {
23522383
"type": "array",
23532384
"items": {

cmd/gomodel/docs/swagger.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,9 @@
17751775
"model": {
17761776
"type": "string"
17771777
},
1778+
"parallel_tool_calls": {
1779+
"type": "boolean"
1780+
},
17781781
"provider": {
17791782
"type": "string"
17801783
},
@@ -1789,6 +1792,16 @@
17891792
},
17901793
"temperature": {
17911794
"type": "number"
1795+
},
1796+
"tool_choice": {
1797+
"description": "string or object"
1798+
},
1799+
"tools": {
1800+
"type": "array",
1801+
"items": {
1802+
"type": "object",
1803+
"additionalProperties": {}
1804+
}
17921805
}
17931806
}
17941807
},
@@ -2036,6 +2049,9 @@
20362049
"role": {
20372050
"type": "string"
20382051
},
2052+
"tool_call_id": {
2053+
"type": "string"
2054+
},
20392055
"tool_calls": {
20402056
"type": "array",
20412057
"items": {
@@ -2285,6 +2301,12 @@
22852301
"core.ResponsesOutputItem": {
22862302
"type": "object",
22872303
"properties": {
2304+
"arguments": {
2305+
"type": "string"
2306+
},
2307+
"call_id": {
2308+
"type": "string"
2309+
},
22882310
"content": {
22892311
"type": "array",
22902312
"items": {
@@ -2294,6 +2316,9 @@
22942316
"id": {
22952317
"type": "string"
22962318
},
2319+
"name": {
2320+
"type": "string"
2321+
},
22972322
"role": {
22982323
"type": "string"
22992324
},
@@ -2329,6 +2354,9 @@
23292354
"model": {
23302355
"type": "string"
23312356
},
2357+
"parallel_tool_calls": {
2358+
"type": "boolean"
2359+
},
23322360
"provider": {
23332361
"type": "string"
23342362
},
@@ -2344,6 +2372,9 @@
23442372
"temperature": {
23452373
"type": "number"
23462374
},
2375+
"tool_choice": {
2376+
"description": "string or object"
2377+
},
23472378
"tools": {
23482379
"type": "array",
23492380
"items": {

internal/core/responses.go

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,27 @@ package core
33
// ResponsesRequest represents the request body for the Responses API.
44
// This is the OpenAI-compatible /v1/responses endpoint.
55
type ResponsesRequest struct {
6-
Model string `json:"model"`
7-
Provider string `json:"provider,omitempty"`
8-
Input interface{} `json:"input" swaggertype:"string" example:"Tell me a joke"` // string or []ResponsesInputItem — see docs for array form
9-
Instructions string `json:"instructions,omitempty"`
10-
Tools []map[string]any `json:"tools,omitempty"`
11-
Temperature *float64 `json:"temperature,omitempty"`
12-
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
13-
Stream bool `json:"stream,omitempty"`
14-
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
15-
Metadata map[string]string `json:"metadata,omitempty"`
16-
Reasoning *Reasoning `json:"reasoning,omitempty"`
6+
Model string `json:"model"`
7+
Provider string `json:"provider,omitempty"`
8+
Input interface{} `json:"input" swaggertype:"string" example:"Tell me a joke"` // string or []ResponsesInputItem — see docs for array form
9+
Instructions string `json:"instructions,omitempty"`
10+
Tools []map[string]any `json:"tools,omitempty"`
11+
ToolChoice any `json:"tool_choice,omitempty"` // string or object
12+
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
13+
Temperature *float64 `json:"temperature,omitempty"`
14+
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
15+
Stream bool `json:"stream,omitempty"`
16+
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
17+
Metadata map[string]string `json:"metadata,omitempty"`
18+
Reasoning *Reasoning `json:"reasoning,omitempty"`
1719
}
1820

1921
// WithStreaming returns a shallow copy of the request with Stream set to true.
2022
// This avoids mutating the caller's request object.
2123
func (r *ResponsesRequest) WithStreaming() *ResponsesRequest {
22-
return &ResponsesRequest{
23-
Model: r.Model,
24-
Provider: r.Provider,
25-
Input: r.Input,
26-
Instructions: r.Instructions,
27-
Tools: r.Tools,
28-
Temperature: r.Temperature,
29-
MaxOutputTokens: r.MaxOutputTokens,
30-
Stream: true,
31-
StreamOptions: r.StreamOptions,
32-
Metadata: r.Metadata,
33-
Reasoning: r.Reasoning,
34-
}
24+
cp := *r
25+
cp.Stream = true
26+
return &cp
3527
}
3628

3729
// ResponsesInputItem represents an input item when Input is an array.
@@ -62,11 +54,14 @@ type ResponsesResponse struct {
6254

6355
// ResponsesOutputItem represents an item in the output array.
6456
type ResponsesOutputItem struct {
65-
ID string `json:"id"`
66-
Type string `json:"type"` // "message", "function_call", etc.
67-
Role string `json:"role,omitempty"`
68-
Status string `json:"status,omitempty"`
69-
Content []ResponsesContentItem `json:"content,omitempty"`
57+
ID string `json:"id"`
58+
Type string `json:"type"` // "message", "function_call", etc.
59+
Role string `json:"role,omitempty"`
60+
Status string `json:"status,omitempty"`
61+
CallID string `json:"call_id,omitempty"`
62+
Name string `json:"name,omitempty"`
63+
Arguments string `json:"arguments,omitempty"`
64+
Content []ResponsesContentItem `json:"content,omitempty"`
7065
}
7166

7267
// ResponsesContentItem represents a content item in the output.

internal/core/types.go

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package core
22

3-
import "encoding/json"
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
)
47

58
// StreamOptions controls streaming behavior options.
69
// This is used to request usage data in streaming responses.
@@ -20,36 +23,90 @@ type Reasoning struct {
2023

2124
// ChatRequest represents the incoming chat completion request
2225
type ChatRequest struct {
23-
Temperature *float64 `json:"temperature,omitempty"`
24-
MaxTokens *int `json:"max_tokens,omitempty"`
25-
Model string `json:"model"`
26-
Provider string `json:"provider,omitempty"`
27-
Messages []Message `json:"messages"`
28-
Stream bool `json:"stream,omitempty"`
29-
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
30-
Reasoning *Reasoning `json:"reasoning,omitempty"`
26+
Temperature *float64 `json:"temperature,omitempty"`
27+
MaxTokens *int `json:"max_tokens,omitempty"`
28+
Model string `json:"model"`
29+
Provider string `json:"provider,omitempty"`
30+
Messages []Message `json:"messages"`
31+
Tools []map[string]any `json:"tools,omitempty"`
32+
ToolChoice any `json:"tool_choice,omitempty"` // string or object
33+
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
34+
Stream bool `json:"stream,omitempty"`
35+
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
36+
Reasoning *Reasoning `json:"reasoning,omitempty"`
3137
}
3238

3339
// WithStreaming returns a shallow copy of the request with Stream set to true.
3440
// This avoids mutating the caller's request object.
3541
func (r *ChatRequest) WithStreaming() *ChatRequest {
36-
return &ChatRequest{
37-
Temperature: r.Temperature,
38-
MaxTokens: r.MaxTokens,
39-
Model: r.Model,
40-
Provider: r.Provider,
41-
Messages: r.Messages,
42-
Stream: true,
43-
StreamOptions: r.StreamOptions,
44-
Reasoning: r.Reasoning,
45-
}
42+
cp := *r
43+
cp.Stream = true
44+
return &cp
4645
}
4746

4847
// Message represents a single message in the chat
4948
type Message struct {
50-
Role string `json:"role"`
51-
Content string `json:"content"`
52-
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
49+
Role string `json:"role"`
50+
Content string `json:"content"`
51+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
52+
ToolCallID string `json:"tool_call_id,omitempty"`
53+
ContentNull bool `json:"-"`
54+
}
55+
56+
// UnmarshalJSON accepts content as string or null for compatibility with
57+
// tool-calling responses that omit assistant text.
58+
func (m *Message) UnmarshalJSON(data []byte) error {
59+
type rawMessage struct {
60+
Role string `json:"role"`
61+
Content json.RawMessage `json:"content"`
62+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
63+
ToolCallID string `json:"tool_call_id,omitempty"`
64+
}
65+
66+
var raw rawMessage
67+
if err := json.Unmarshal(data, &raw); err != nil {
68+
return err
69+
}
70+
71+
m.Role = raw.Role
72+
m.ContentNull = false
73+
switch trimmed := bytes.TrimSpace(raw.Content); {
74+
case len(trimmed) == 0:
75+
m.Content = ""
76+
case bytes.Equal(trimmed, []byte("null")):
77+
m.Content = ""
78+
m.ContentNull = true
79+
default:
80+
if err := json.Unmarshal(trimmed, &m.Content); err != nil {
81+
return err
82+
}
83+
}
84+
m.ToolCalls = raw.ToolCalls
85+
m.ToolCallID = raw.ToolCallID
86+
87+
return nil
88+
}
89+
90+
// MarshalJSON preserves explicit null content for tool-calling assistant messages.
91+
func (m Message) MarshalJSON() ([]byte, error) {
92+
type rawMessage struct {
93+
Role string `json:"role"`
94+
Content any `json:"content"`
95+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
96+
ToolCallID string `json:"tool_call_id,omitempty"`
97+
}
98+
99+
content := any(m.Content)
100+
if m.ContentNull && m.Content == "" {
101+
content = nil
102+
}
103+
104+
return json.Marshal(rawMessage{
105+
Role: m.Role,
106+
Content: content,
107+
ToolCalls: m.ToolCalls,
108+
ToolCallID: m.ToolCallID,
109+
})
53110
}
54111

55112
// ToolCall represents a single tool invocation emitted by a model.

0 commit comments

Comments
 (0)