diff --git a/internal/providers/openai.go b/internal/providers/openai.go index da525cdc..721bc179 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -371,7 +371,7 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream } } toolCalls[i] = map[string]any{ - "id": truncateToolCallID(tc.ID), + "id": p.wireToolCallID(tc.ID), "type": "function", "function": fn, } @@ -380,7 +380,7 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream } if m.ToolCallID != "" { - msg["tool_call_id"] = truncateToolCallID(m.ToolCallID) + msg["tool_call_id"] = p.wireToolCallID(m.ToolCallID) } msgs = append(msgs, msg) @@ -605,6 +605,23 @@ func clampedLimit(body map[string]any) any { const maxToolCallIDLen = 40 +// normalizeMistralToolCallID deterministically maps any tool call ID to a +// 9-character alphanumeric string required by the Mistral API. +// Uses SHA-256 of the full ID to avoid prefix-dependent collisions. +func normalizeMistralToolCallID(id string) string { + h := sha256.Sum256([]byte(id)) + return hex.EncodeToString(h[:])[:9] +} + +// wireToolCallID dispatches to Mistral-specific normalization (9-char alnum) +// or the standard OpenAI truncation (40-char max) based on the provider. +func (p *OpenAIProvider) wireToolCallID(id string) string { + if p.name == "mistral" || p.providerType == "mistral" { + return normalizeMistralToolCallID(id) + } + return truncateToolCallID(id) +} + // truncateToolCallID deterministically fits tool call IDs into OpenAI's 40-char // limit. Prefix truncation can alias distinct legacy IDs that only diverge after // byte 40, so we hash the full original ID when shortening is needed. diff --git a/internal/providers/openai_test.go b/internal/providers/openai_test.go index b80658cf..faa2194e 100644 --- a/internal/providers/openai_test.go +++ b/internal/providers/openai_test.go @@ -1,6 +1,7 @@ package providers import ( + "regexp" "strings" "testing" ) @@ -258,3 +259,77 @@ func TestBuildRequestBody_LegacyLongToolCallIDsStayUnique(t *testing.T) { t.Fatalf("second tool result ID = %q, want %q", got, assistantID2) } } + +var mistralWireIDRe = regexp.MustCompile(`^[0-9a-f]{9}$`) + +func TestNormalizeMistralToolCallID_DeterministicNineChars(t *testing.T) { + id := "call_85f419357e554e8983a7edb4d2317e93e15" + a := normalizeMistralToolCallID(id) + b := normalizeMistralToolCallID(id) + if a != b { + t.Fatalf("normalizeMistralToolCallID not deterministic: %q vs %q", a, b) + } + if !mistralWireIDRe.MatchString(a) { + t.Fatalf("got %q, want exactly 9 hex chars", a) + } +} + +func TestNormalizeMistralToolCallID_DistinctIDsStayUnique(t *testing.T) { + // IDs sharing a long prefix must not collide after normalization. + id1 := "call_a1b2c3d4e5f6a1b2c3d4e5f6_suffix1" + id2 := "call_a1b2c3d4e5f6a1b2c3d4e5f6_suffix2" + if normalizeMistralToolCallID(id1) == normalizeMistralToolCallID(id2) { + t.Fatal("distinct IDs must not collide after normalization") + } +} + +func TestBuildRequestBody_MistralToolCallIDsWireFormat(t *testing.T) { + p := NewOpenAIProvider("mistral", "key", "https://api.mistral.ai/v1", "mistral-large-latest") + longID := "call_85f419357e554e8983a7edb4d2317e93e15" + + req := ChatRequest{ + Messages: []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{ + {ID: longID, Name: "test_fn", Arguments: map[string]any{"x": 1}}, + }, + }, + {Role: "tool", ToolCallID: longID, Content: "ok"}, + {Role: "user", Content: "next"}, + }, + } + + body := p.buildRequestBody("mistral-large-latest", req, false) + msgs := body["messages"].([]map[string]any) + + var assistantID, toolResultID string + for _, msg := range msgs { + if tcs, ok := msg["tool_calls"]; ok { + toolCalls := tcs.([]map[string]any) + assistantID = toolCalls[0]["id"].(string) + } + if tcid, ok := msg["tool_call_id"]; ok { + toolResultID = tcid.(string) + } + } + + if assistantID != toolResultID { + t.Fatalf("IDs must match: tool_calls.id=%q tool_call_id=%q", assistantID, toolResultID) + } + if !mistralWireIDRe.MatchString(assistantID) { + t.Fatalf("mistral wire id %q must be 9 hex chars", assistantID) + } +} + +func TestBuildRequestBody_MistralDBProviderTypeDetected(t *testing.T) { + // DB-loaded Mistral providers use providerType="mistral" with a user-chosen name. + p := NewOpenAIProvider("my-mistral", "key", "https://api.mistral.ai/v1", "mistral-large-latest") + p.WithProviderType("mistral") + + id := "call_85f419357e554e8983a7edb4d2317e93e15" + got := p.wireToolCallID(id) + if !mistralWireIDRe.MatchString(got) { + t.Fatalf("DB provider with providerType=mistral: got %q, want 9 hex chars", got) + } +}