feat: add ability to guaranty tool call id uniquenes

Kujtim Hoxha created

Change summary

openai/language_model.go                                                          |  28 
openai/language_model_hooks.go                                                    |   6 
openrouter/openrouter.go                                                          |  33 
providertests/common_test.go                                                      |   4 
providertests/openrouter_test.go                                                  | 102 
providertests/testdata/TestWithUniqueToolCallIDs/stream_unique_tool_call_ids.yaml | 123 
providertests/testdata/TestWithUniqueToolCallIDs/unique_tool_call_ids.yaml        |  26 
7 files changed, 307 insertions(+), 15 deletions(-)

Detailed changes

openai/language_model.go 🔗

@@ -21,6 +21,8 @@ type languageModel struct {
 	provider                   string
 	modelID                    string
 	client                     openai.Client
+	uniqueToolCallIds          bool
+	generateIDFunc             LanguageModelGenerateIDFunc
 	prepareCallFunc            LanguageModelPrepareCallFunc
 	mapFinishReasonFunc        LanguageModelMapFinishReasonFunc
 	extraContentFunc           LanguageModelExtraContentFunc
@@ -68,11 +70,24 @@ func WithLanguageModelStreamUsageFunc(fn LanguageModelStreamUsageFunc) LanguageM
 	}
 }
 
+func WithLanguageUniqueToolCallIds() LanguageModelOption {
+	return func(l *languageModel) {
+		l.uniqueToolCallIds = true
+	}
+}
+
+func WithLanguageModelGenerateIDFunc(fn LanguageModelGenerateIDFunc) LanguageModelOption {
+	return func(l *languageModel) {
+		l.generateIDFunc = fn
+	}
+}
+
 func newLanguageModel(modelID string, provider string, client openai.Client, opts ...LanguageModelOption) languageModel {
 	model := languageModel{
 		modelID:                    modelID,
 		provider:                   provider,
 		client:                     client,
+		generateIDFunc:             defaultGenerateID,
 		prepareCallFunc:            defaultPrepareLanguageModelCall,
 		mapFinishReasonFunc:        defaultMapFinishReason,
 		usageFunc:                  defaultUsage,
@@ -261,8 +276,8 @@ func (o languageModel) Generate(ctx context.Context, call ai.Call) (*ai.Response
 	}
 	for _, tc := range choice.Message.ToolCalls {
 		toolCallID := tc.ID
-		if toolCallID == "" {
-			toolCallID = uuid.NewString()
+		if toolCallID == "" || o.uniqueToolCallIds {
+			toolCallID = o.generateIDFunc()
 		}
 		content = append(content, ai.ToolCallContent{
 			ProviderExecuted: false, // TODO: update when handling other tools
@@ -419,6 +434,15 @@ func (o languageModel) Stream(ctx context.Context, call ai.Call) (ai.StreamRespo
 								return
 							}
 
+							// some providers do not send this as a unique id
+							// for some usecases in crush we need this ID to be unique.
+							// it won't change the behavior on the provider side because the
+							// provider only cares about the tool call id matching the result
+							// and in our case that will still be the case
+							if o.uniqueToolCallIds {
+								toolCallDelta.ID = o.generateIDFunc()
+							}
+
 							if !yield(ai.StreamPart{
 								Type:         ai.StreamPartTypeToolInputStart,
 								ID:           toolCallDelta.ID,

openai/language_model_hooks.go 🔗

@@ -4,12 +4,14 @@ import (
 	"fmt"
 
 	"github.com/charmbracelet/fantasy/ai"
+	"github.com/google/uuid"
 	"github.com/openai/openai-go/v2"
 	"github.com/openai/openai-go/v2/packages/param"
 	"github.com/openai/openai-go/v2/shared"
 )
 
 type (
+	LanguageModelGenerateIDFunc             = func() string
 	LanguageModelPrepareCallFunc            = func(model ai.LanguageModel, params *openai.ChatCompletionNewParams, call ai.Call) ([]ai.CallWarning, error)
 	LanguageModelMapFinishReasonFunc        = func(choice openai.ChatCompletionChoice) ai.FinishReason
 	LanguageModelUsageFunc                  = func(choice openai.ChatCompletion) (ai.Usage, ai.ProviderOptionsData)
@@ -19,6 +21,10 @@ type (
 	LanguageModelStreamProviderMetadataFunc = func(choice openai.ChatCompletionChoice, metadata ai.ProviderMetadata) ai.ProviderMetadata
 )
 
+func defaultGenerateID() string {
+	return uuid.NewString()
+}
+
 func defaultPrepareLanguageModelCall(model ai.LanguageModel, params *openai.ChatCompletionNewParams, call ai.Call) ([]ai.CallWarning, error) {
 	if call.ProviderOptions == nil {
 		return nil, nil

openrouter/openrouter.go 🔗

@@ -9,7 +9,8 @@ import (
 )
 
 type options struct {
-	openaiOptions []openai.Option
+	openaiOptions        []openai.Option
+	languageModelOptions []openai.LanguageModelOption
 }
 
 const (
@@ -24,19 +25,21 @@ func New(opts ...Option) ai.Provider {
 		openaiOptions: []openai.Option{
 			openai.WithName(Name),
 			openai.WithBaseURL(DefaultURL),
-			openai.WithLanguageModelOptions(
-				openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),
-				openai.WithLanguageModelUsageFunc(languageModelUsage),
-				openai.WithLanguageModelStreamUsageFunc(languageModelStreamUsage),
-				openai.WithLanguageModelStreamExtraFunc(languageModelStreamExtra),
-				openai.WithLanguageModelExtraContentFunc(languageModelExtraContent),
-				openai.WithLanguageModelMapFinishReasonFunc(languageModelMapFinishReason),
-			),
+		},
+		languageModelOptions: []openai.LanguageModelOption{
+			openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),
+			openai.WithLanguageModelUsageFunc(languageModelUsage),
+			openai.WithLanguageModelStreamUsageFunc(languageModelStreamUsage),
+			openai.WithLanguageModelStreamExtraFunc(languageModelStreamExtra),
+			openai.WithLanguageModelExtraContentFunc(languageModelExtraContent),
+			openai.WithLanguageModelMapFinishReasonFunc(languageModelMapFinishReason),
 		},
 	}
 	for _, o := range opts {
 		o(&providerOptions)
 	}
+
+	providerOptions.openaiOptions = append(providerOptions.openaiOptions, openai.WithLanguageModelOptions(providerOptions.languageModelOptions...))
 	return openai.New(providerOptions.openaiOptions...)
 }
 
@@ -64,6 +67,18 @@ func WithHTTPClient(client option.HTTPClient) Option {
 	}
 }
 
+func WithLanguageUniqueToolCallIds() Option {
+	return func(l *options) {
+		l.languageModelOptions = append(l.languageModelOptions, openai.WithLanguageUniqueToolCallIds())
+	}
+}
+
+func WithLanguageModelGenerateIDFunc(fn openai.LanguageModelGenerateIDFunc) Option {
+	return func(l *options) {
+		l.languageModelOptions = append(l.languageModelOptions, openai.WithLanguageModelGenerateIDFunc(fn))
+	}
+}
+
 func structToMapJSON(s any) (map[string]any, error) {
 	var result map[string]any
 	jsonBytes, err := json.Marshal(s)

providertests/common_test.go 🔗

@@ -147,10 +147,6 @@ func testTool(t *testing.T, pair builderPair) {
 }
 
 func testMultiTool(t *testing.T, pair builderPair) {
-	type WeatherInput struct {
-		Location string `json:"location" description:"the city"`
-	}
-
 	type CalculatorInput struct {
 		A int `json:"a" description:"first number"`
 		B int `json:"b" description:"second number"`

providertests/openrouter_test.go 🔗

@@ -1,12 +1,16 @@
 package providertests
 
 import (
+	"context"
 	"net/http"
 	"os"
+	"strconv"
+	"strings"
 	"testing"
 
 	"github.com/charmbracelet/fantasy/ai"
 	"github.com/charmbracelet/fantasy/openrouter"
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
 	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
 )
@@ -51,6 +55,104 @@ func testOpenrouterThinking(t *testing.T, result *ai.AgentResult) {
 	require.Greater(t, reasoningContentCount, 0)
 }
 
+func TestWithUniqueToolCallIDs(t *testing.T) {
+	type CalculatorInput struct {
+		A int `json:"a" description:"first number"`
+		B int `json:"b" description:"second number"`
+	}
+
+	addTool := ai.NewAgentTool(
+		"add",
+		"Add two numbers",
+		func(ctx context.Context, input CalculatorInput, _ ai.ToolCall) (ai.ToolResponse, error) {
+			result := input.A + input.B
+			return ai.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
+		},
+	)
+	multiplyTool := ai.NewAgentTool(
+		"multiply",
+		"Multiply two numbers",
+		func(ctx context.Context, input CalculatorInput, _ ai.ToolCall) (ai.ToolResponse, error) {
+			result := input.A * input.B
+			return ai.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
+		},
+	)
+	checkResult := func(t *testing.T, result *ai.AgentResult) {
+		require.Len(t, result.Steps, 2)
+
+		var toolCalls []ai.ToolCallContent
+		for _, content := range result.Steps[0].Content {
+			if content.GetType() == ai.ContentTypeToolCall {
+				toolCalls = append(toolCalls, content.(ai.ToolCallContent))
+			}
+		}
+		for _, tc := range toolCalls {
+			require.False(t, tc.Invalid)
+			require.Contains(t, tc.ToolCallID, "test-")
+		}
+		require.Len(t, toolCalls, 2)
+
+		finalText := result.Response.Content.Text()
+		require.Contains(t, finalText, "5", "expected response to contain '5', got: %q", finalText)
+		require.Contains(t, finalText, "6", "expected response to contain '6', got: %q", finalText)
+	}
+
+	generateIDFunc := func() string {
+		return "test-" + uuid.NewString()
+	}
+
+	t.Run("unique tool call ids", func(t *testing.T) {
+		r := newRecorder(t)
+
+		provider := openrouter.New(
+			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
+			openrouter.WithHTTPClient(&http.Client{Transport: r}),
+			openrouter.WithLanguageUniqueToolCallIds(),
+			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
+		)
+		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
+		require.NoError(t, err, "failed to build language model")
+
+		agent := ai.NewAgent(
+			languageModel,
+			ai.WithSystemPrompt("You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS."),
+			ai.WithTools(addTool),
+			ai.WithTools(multiplyTool),
+		)
+		result, err := agent.Generate(t.Context(), ai.AgentCall{
+			Prompt:          "Add and multiply the number 2 and 3",
+			MaxOutputTokens: ai.IntOption(4000),
+		})
+		require.NoError(t, err, "failed to generate")
+		checkResult(t, result)
+	})
+	t.Run("stream unique tool call ids", func(t *testing.T) {
+		r := newRecorder(t)
+
+		provider := openrouter.New(
+			openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),
+			openrouter.WithHTTPClient(&http.Client{Transport: r}),
+			openrouter.WithLanguageUniqueToolCallIds(),
+			openrouter.WithLanguageModelGenerateIDFunc(generateIDFunc),
+		)
+		languageModel, err := provider.LanguageModel("moonshotai/kimi-k2-0905")
+		require.NoError(t, err, "failed to build language model")
+
+		agent := ai.NewAgent(
+			languageModel,
+			ai.WithSystemPrompt("You are a helpful assistant. Always use both add and multiply at the same time."),
+			ai.WithTools(addTool),
+			ai.WithTools(multiplyTool),
+		)
+		result, err := agent.Stream(t.Context(), ai.AgentStreamCall{
+			Prompt:          "Add and multiply the number 2 and 3",
+			MaxOutputTokens: ai.IntOption(4000),
+		})
+		require.NoError(t, err, "failed to generate")
+		checkResult(t, result)
+	})
+}
+
 func builderOpenRouterKimiK2(r *recorder.Recorder) (ai.LanguageModel, error) {
 	provider := openrouter.New(
 		openrouter.WithAPIKey(os.Getenv("OPENROUTER_API_KEY")),

providertests/testdata/TestWithUniqueToolCallIDs/stream_unique_tool_call_ids.yaml 🔗

@@ -0,0 +1,207 @@
+---
+version: 2
+interactions:
+  - id: 0
+    request:
+        proto: HTTP/1.1
+        proto_major: 1
+        proto_minor: 1
+        content_length: 890
+        host: ""
+        body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system"},{"content":"Add and multiply the number 2 and 3","role":"user"}],"model":"moonshotai/kimi-k2-0905","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true},"stream":true}'
+        headers:
+            Accept:
+              - application/json
+            Content-Type:
+              - application/json
+            User-Agent:
+              - OpenAI/Go 2.3.0
+        url: https://openrouter.ai/api/v1/chat/completions
+        method: POST
+    response:
+        proto: HTTP/2.0
+        proto_major: 2
+        proto_minor: 0
+        content_length: -1
+        body: |+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":"I'll"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" add"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" multiply"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" the"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" numbers"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" "},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":"2"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" "},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":"3"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" for"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":" you"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":"functions.add:0","index":0,"type":"function","function":{"name":"add","arguments":""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"{\"a"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"\":"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":" "}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"2"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":","}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":" \""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"b"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"\":"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":" "}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"3"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"}"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":"functions.multiply:1","index":1,"type":"function","function":{"name":"multiply","arguments":""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"{\"a"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"\":"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":" "}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"2"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":","}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":" \""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"b"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"\":"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":" "}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"3"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":null,"index":1,"type":"function","function":{"name":null,"arguments":"}"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"tool_calls","native_finish_reason":"tool_calls","logprobs":null}]}
+
+            data: {"id":"gen-1758792034-4DFG24Xt5SzxfAVIPAsj","provider":"Chutes","model":"moonshotai/kimi-k2-0905","object":"chat.completion.chunk","created":1758792034,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":197,"completion_tokens":55,"total_tokens":252,"cost":0.00015846,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00007486,"upstream_inference_completions_cost":0.0000836},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+            data: [DONE]
+
+        headers:
+            Content-Type:
+              - text/event-stream
+        status: 200 OK
+        code: 200
+        duration: 1.0912445s
+  - id: 1
+    request:
+        proto: HTTP/1.1
+        proto_major: 1
+        proto_minor: 1
+        content_length: 1432
+        host: ""

providertests/testdata/TestWithUniqueToolCallIDs/unique_tool_call_ids.yaml 🔗

@@ -0,0 +1,63 @@
+---
+version: 2
+interactions:
+  - id: 0
+    request:
+        proto: HTTP/1.1
+        proto_major: 1
+        proto_minor: 1
+        content_length: 853
+        host: ""
+        body: '{"messages":[{"content":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","role":"system"},{"content":"Add and multiply the number 2 and 3","role":"user"}],"model":"moonshotai/kimi-k2-0905","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true}}'
+        headers:
+            Accept:
+              - application/json
+            Content-Type:
+              - application/json
+            User-Agent:
+              - OpenAI/Go 2.3.0
+        url: https://openrouter.ai/api/v1/chat/completions
+        method: POST
+    response:
+        proto: HTTP/2.0
+        proto_major: 2
+        proto_minor: 0
+        content_length: -1
+        uncompressed: true