@@ -2259,6 +2259,18 @@ func (sms *streamingMockServer) prepareErrorStreamResponse() {
sms.chunks = chunks
}
+func (sms *streamingMockServer) prepareToolStreamResponseWithEmptyArgs() {
+ chunks := []string{
+ // Tool call start with empty arguments (like Copilot sometimes does)
+ `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_empty_args","type":"function","function":{"name":"test-tool","arguments":""}}]},"logprobs":null,"finish_reason":null}]}` + "\n\n",
+ // Finish without any argument deltas
+ `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}` + "\n\n",
+ `data: {"id":"chatcmpl-emptyargs","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}` + "\n\n",
+ "data: [DONE]\n\n",
+ }
+ sms.chunks = chunks
+}
+
func collectStreamParts(stream fantasy.StreamResponse) ([]fantasy.StreamPart, error) {
var parts []fantasy.StreamPart
for part := range stream {
@@ -2416,6 +2428,72 @@ func TestDoStream(t *testing.T) {
require.Equal(t, `{"value":"Sparkle Day"}`, fullInput.String())
})
+ t.Run("should handle tool calls with empty arguments", func(t *testing.T) {
+ t.Parallel()
+
+ server := newStreamingMockServer()
+ defer server.close()
+
+ server.prepareToolStreamResponseWithEmptyArgs()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.server.URL),
+ )
+ require.NoError(t, err)
+ model, _ := provider.LanguageModel(t.Context(), "gpt-3.5-turbo")
+
+ stream, err := model.Stream(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{
+ fantasy.FunctionTool{
+ Name: "test-tool",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "value": map[string]any{
+ "type": "string",
+ },
+ },
+ "required": []string{"value"},
+ "additionalProperties": false,
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ },
+ },
+ },
+ })
+
+ require.NoError(t, err)
+
+ parts, err := collectStreamParts(stream)
+ require.NoError(t, err)
+
+ // Find tool-related parts
+ toolInputStart, toolInputEnd, toolCall := -1, -1, -1
+
+ for i, part := range parts {
+ switch part.Type {
+ case fantasy.StreamPartTypeToolInputStart:
+ toolInputStart = i
+ require.Equal(t, "call_empty_args", part.ID)
+ require.Equal(t, "test-tool", part.ToolCallName)
+ case fantasy.StreamPartTypeToolInputEnd:
+ toolInputEnd = i
+ require.Equal(t, "call_empty_args", part.ID)
+ case fantasy.StreamPartTypeToolCall:
+ toolCall = i
+ require.Equal(t, "call_empty_args", part.ID)
+ require.Equal(t, "test-tool", part.ToolCallName)
+ // Empty arguments should be normalized to "{}"
+ require.Equal(t, "{}", part.ToolCallInput)
+ }
+ }
+
+ require.NotEqual(t, -1, toolInputStart, "expected ToolInputStart part")
+ require.NotEqual(t, -1, toolInputEnd, "expected ToolInputEnd part")
+ require.NotEqual(t, -1, toolCall, "expected ToolCall part")
+ })
+
t.Run("should stream annotations/citations", func(t *testing.T) {
t.Parallel()