fix(openai): normalize Mistral tool call IDs (#669)

Fix Mistral tool call ID formatting on the outbound OpenAI-compatible payload.

For provider `mistral` only, normalizes both `tool_calls[].id` and `tool`
message `tool_call_id` to a 9-character hex string (SHA-256 based) before
sending the request. Other providers keep the existing `truncateToolCallID`
behavior unchanged.

- Hash full ID via SHA-256 to avoid prefix-dependent collisions
- Detect Mistral via both provider name and providerType (DB-loaded support)
- Add collision uniqueness and DB provider detection tests
This commit is contained in:
Tuan-TC authored and GitHub committed 2026-04-04 07:26:04 +07:00
1 parent 2b1180f59d
commit 4d9c286aae
2 files changed
+94 -2

No files matched your search

+19 -2
View File
@@ -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.
+75
View File
@@ -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)
}
}