Skip to content

Commit 88ee41c

Browse files
Preserve null tool-call content across adapters
1 parent a3bf895 commit 88ee41c

6 files changed

Lines changed: 114 additions & 26 deletions

File tree

internal/guardrails/guardrails.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ import (
2828
// This is the normalized DTO that all text guardrails operate on,
2929
// decoupled from concrete API request types.
3030
type Message struct {
31-
Role string // "system", "user", "assistant", "tool"
32-
Content string
33-
ToolCalls []core.ToolCall
34-
ToolCallID string
31+
Role string // "system", "user", "assistant", "tool"
32+
Content string
33+
ToolCalls []core.ToolCall
34+
ToolCallID string
35+
ContentNull bool
3536
}
3637

3738
// Guardrail processes a message list and returns the (possibly modified) messages or an error.

internal/guardrails/provider.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,11 @@ func chatToMessages(req *core.ChatRequest) []Message {
314314
msgs := make([]Message, len(req.Messages))
315315
for i, m := range req.Messages {
316316
msgs[i] = Message{
317-
Role: m.Role,
318-
Content: m.Content,
319-
ToolCalls: cloneToolCalls(m.ToolCalls),
320-
ToolCallID: m.ToolCallID,
317+
Role: m.Role,
318+
Content: m.Content,
319+
ToolCalls: cloneToolCalls(m.ToolCalls),
320+
ToolCallID: m.ToolCallID,
321+
ContentNull: m.ContentNull,
321322
}
322323
}
323324
return msgs
@@ -328,10 +329,11 @@ func applyMessagesToChat(req *core.ChatRequest, msgs []Message) *core.ChatReques
328329
coreMessages := make([]core.Message, len(msgs))
329330
for i, m := range msgs {
330331
coreMessages[i] = core.Message{
331-
Role: m.Role,
332-
Content: m.Content,
333-
ToolCalls: cloneToolCalls(m.ToolCalls),
334-
ToolCallID: m.ToolCallID,
332+
Role: m.Role,
333+
Content: m.Content,
334+
ToolCalls: cloneToolCalls(m.ToolCalls),
335+
ToolCallID: m.ToolCallID,
336+
ContentNull: m.ContentNull,
335337
}
336338
}
337339
result := *req

internal/guardrails/provider_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,37 @@ func TestChatAdaptersCloneToolCalls(t *testing.T) {
256256
}
257257
}
258258

259+
func TestChatAdaptersPreserveContentNull(t *testing.T) {
260+
req := &core.ChatRequest{
261+
Messages: []core.Message{
262+
{
263+
Role: "assistant",
264+
ContentNull: true,
265+
ToolCalls: []core.ToolCall{
266+
{
267+
ID: "call_123",
268+
Type: "function",
269+
Function: core.FunctionCall{
270+
Name: "lookup_weather",
271+
Arguments: `{"city":"Warsaw"}`,
272+
},
273+
},
274+
},
275+
},
276+
},
277+
}
278+
279+
msgs := chatToMessages(req)
280+
if !msgs[0].ContentNull {
281+
t.Fatal("chatToMessages should preserve ContentNull")
282+
}
283+
284+
chatReq := applyMessagesToChat(&core.ChatRequest{}, msgs)
285+
if !chatReq.Messages[0].ContentNull {
286+
t.Fatal("applyMessagesToChat should preserve ContentNull")
287+
}
288+
}
289+
259290
// --- Responses adapter integration tests ---
260291

261292
func TestGuardedProvider_Responses_AppliesGuardrails(t *testing.T) {

internal/providers/anthropic/anthropic.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,19 @@ func buildAnthropicMessageContent(msg core.Message) (any, error) {
522522
})
523523
}
524524
for _, toolCall := range msg.ToolCalls {
525+
toolCallID := providers.ResponsesFunctionCallCallID(strings.TrimSpace(toolCall.ID))
526+
toolName := strings.TrimSpace(toolCall.Function.Name)
527+
if toolName == "" {
528+
return nil, core.NewInvalidRequestError("tool_call.function.name is required", nil)
529+
}
525530
input, err := parseToolCallArguments(toolCall.Function.Arguments)
526531
if err != nil {
527532
return nil, err
528533
}
529534
blocks = append(blocks, anthropicMessageContentBlock{
530535
Type: "tool_use",
531-
ID: toolCall.ID,
532-
Name: toolCall.Function.Name,
536+
ID: toolCallID,
537+
Name: toolName,
533538
Input: input,
534539
})
535540
}
@@ -870,7 +875,10 @@ func (sc *streamConverter) convertEvent(event *anthropicStreamEvent) string {
870875

871876
initialArguments := extractInitialToolArguments(event.ContentBlock.Input)
872877
state.PlaceholderObject = initialArguments == "{}"
873-
if initialArguments != "" {
878+
emittedArguments := initialArguments
879+
if state.PlaceholderObject {
880+
emittedArguments = ""
881+
} else if initialArguments != "" {
874882
_, _ = state.Arguments.WriteString(initialArguments)
875883
}
876884
sc.toolCalls[event.Index] = state
@@ -883,7 +891,7 @@ func (sc *streamConverter) convertEvent(event *anthropicStreamEvent) string {
883891
"type": "function",
884892
"function": map[string]any{
885893
"name": state.Name,
886-
"arguments": initialArguments,
894+
"arguments": emittedArguments,
887895
},
888896
},
889897
},
@@ -1750,21 +1758,32 @@ func (sc *responsesStreamConverter) newResponsesToolCallState(contentBlock *anth
17501758

17511759
initialArguments := extractInitialToolArguments(contentBlock.Input)
17521760
state.PlaceholderObject = initialArguments == "{}"
1753-
if initialArguments != "" {
1761+
if initialArguments != "" && !state.PlaceholderObject {
17541762
_, _ = state.Arguments.WriteString(initialArguments)
17551763
}
17561764

17571765
return state
17581766
}
17591767

1760-
func (sc *responsesStreamConverter) renderResponsesToolCallItem(state *responsesToolCallState, status string) map[string]any {
1768+
func (sc *responsesStreamConverter) toolCallArguments(state *responsesToolCallState) string {
1769+
if state.PlaceholderObject && state.Arguments.Len() == 0 {
1770+
return "{}"
1771+
}
1772+
return state.Arguments.String()
1773+
}
1774+
1775+
func (sc *responsesStreamConverter) renderResponsesToolCallItem(state *responsesToolCallState, status string, includePlaceholder bool) map[string]any {
1776+
arguments := state.Arguments.String()
1777+
if includePlaceholder {
1778+
arguments = sc.toolCallArguments(state)
1779+
}
17611780
item := map[string]any{
17621781
"id": state.ID,
17631782
"type": "function_call",
17641783
"status": status,
17651784
"call_id": state.CallID,
17661785
"name": state.Name,
1767-
"arguments": state.Arguments.String(),
1786+
"arguments": arguments,
17681787
}
17691788
return item
17701789
}
@@ -1797,7 +1816,7 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st
17971816
sc.toolCalls[event.Index] = state
17981817
return sc.writeResponsesEvent("response.output_item.added", map[string]any{
17991818
"type": "response.output_item.added",
1800-
"item": sc.renderResponsesToolCallItem(state, "in_progress"),
1819+
"item": sc.renderResponsesToolCallItem(state, "in_progress", false),
18011820
"output_index": state.OutputIndex,
18021821
})
18031822
}
@@ -1855,10 +1874,10 @@ func (sc *responsesStreamConverter) convertEvent(event *anthropicStreamEvent) st
18551874
"type": "response.function_call_arguments.done",
18561875
"item_id": state.ID,
18571876
"output_index": state.OutputIndex,
1858-
"arguments": state.Arguments.String(),
1877+
"arguments": sc.toolCallArguments(state),
18591878
}) + sc.writeResponsesEvent("response.output_item.done", map[string]any{
18601879
"type": "response.output_item.done",
1861-
"item": sc.renderResponsesToolCallItem(state, "completed"),
1880+
"item": sc.renderResponsesToolCallItem(state, "completed", true),
18621881
"output_index": state.OutputIndex,
18631882
})
18641883

internal/providers/anthropic/anthropic_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ data: {"type":"message_stop"}
395395
}
396396
function, _ := toolCall["function"].(map[string]interface{})
397397

398-
if toolCall["id"] == "toolu_123" && function["name"] == "lookup_weather" && function["arguments"] == "{}" {
398+
if toolCall["id"] == "toolu_123" && function["name"] == "lookup_weather" && function["arguments"] == "" {
399399
foundToolStart = true
400400
}
401401
if arguments, _ := function["arguments"].(string); arguments != "" && arguments != "{}" {
@@ -1013,6 +1013,41 @@ func TestConvertToAnthropicRequest_ToolArgumentsMustBeJSONObject(t *testing.T) {
10131013
}
10141014
}
10151015

1016+
func TestConvertToAnthropicRequest_NormalizesToolCallIDAndName(t *testing.T) {
1017+
result, err := convertToAnthropicRequest(&core.ChatRequest{
1018+
Model: "claude-sonnet-4-5-20250929",
1019+
Messages: []core.Message{
1020+
{
1021+
Role: "assistant",
1022+
ToolCalls: []core.ToolCall{
1023+
{
1024+
ID: " ",
1025+
Type: "function",
1026+
Function: core.FunctionCall{
1027+
Name: " lookup_weather ",
1028+
Arguments: `{"city":"Warsaw"}`,
1029+
},
1030+
},
1031+
},
1032+
},
1033+
},
1034+
})
1035+
if err != nil {
1036+
t.Fatalf("convertToAnthropicRequest() error = %v, want nil", err)
1037+
}
1038+
1039+
blocks, ok := result.Messages[0].Content.([]anthropicMessageContentBlock)
1040+
if !ok || len(blocks) != 1 {
1041+
t.Fatalf("content = %#v, want one tool_use block", result.Messages[0].Content)
1042+
}
1043+
if blocks[0].Name != "lookup_weather" {
1044+
t.Fatalf("tool name = %q, want lookup_weather", blocks[0].Name)
1045+
}
1046+
if blocks[0].ID == "" {
1047+
t.Fatal("tool id should not be empty")
1048+
}
1049+
}
1050+
10161051
func TestConvertFromAnthropicResponse(t *testing.T) {
10171052
resp := &anthropicResponse{
10181053
ID: "msg_123",
@@ -1824,7 +1859,7 @@ data: {"type":"message_stop"}
18241859
}
18251860
case "response.output_item.added":
18261861
item, _ := event.Payload["item"].(map[string]interface{})
1827-
if item["type"] == "function_call" && item["call_id"] == "toolu_123" && item["name"] == "lookup_weather" && item["arguments"] == "{}" && event.Payload["output_index"] == float64(1) {
1862+
if item["type"] == "function_call" && item["call_id"] == "toolu_123" && item["name"] == "lookup_weather" && item["arguments"] == "" && event.Payload["output_index"] == float64(1) {
18281863
foundAdded = true
18291864
}
18301865
case "response.function_call_arguments.delta":
@@ -1917,7 +1952,7 @@ data: {"type":"message_stop"}
19171952
switch event.Name {
19181953
case "response.output_item.added":
19191954
item, _ := event.Payload["item"].(map[string]interface{})
1920-
if item["type"] == "function_call" && item["arguments"] == "{}" {
1955+
if item["type"] == "function_call" && item["arguments"] == "" {
19211956
foundAdded = true
19221957
}
19231958
case "response.function_call_arguments.done":

tests/contract/testdata/golden/openai/chat_with_tools.golden.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"finish_reason": "tool_calls",
55
"index": 0,
66
"message": {
7-
"content": "",
7+
"content": null,
88
"role": "assistant",
99
"tool_calls": [
1010
{

0 commit comments

Comments
 (0)