mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
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:
1 parent
2b1180f59d
commit
4d9c286aae
2 files changed
+94
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user