|
|
|
@@ -0,0 +1,486 @@
|
|
|
|
|
package openai
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestMarshalForUpstream_StringContent(t *testing.T) {
|
|
|
|
|
req := ChatCompletionRequest{
|
|
|
|
|
Model: "GLM-4.7",
|
|
|
|
|
Messages: []map[string]any{{"role": "user", "content": "hello"}},
|
|
|
|
|
Stream: true,
|
|
|
|
|
}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
messages, _ := m["messages"].([]any)
|
|
|
|
|
msg, _ := messages[0].(map[string]any)
|
|
|
|
|
if content, _ := msg["content"].(string); content != "hello" {
|
|
|
|
|
t.Fatalf("content = %v, want %q", msg["content"], "hello")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMarshalForUpstream_FlattensArrayContent verifies that multi-part
|
|
|
|
|
// content arrays (the format used by OpenAI SDKs and AI coding tools) are
|
|
|
|
|
// flattened into a plain string so the Zhanlu upstream gateway accepts the
|
|
|
|
|
// request instead of returning HTTP 400.
|
|
|
|
|
func TestMarshalForUpstream_FlattensArrayContent(t *testing.T) {
|
|
|
|
|
req := ChatCompletionRequest{
|
|
|
|
|
Model: "zhanlu/deepseek-v4-pro",
|
|
|
|
|
Messages: []map[string]any{
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "first part "},
|
|
|
|
|
map[string]any{"type": "text", "text": "second part"},
|
|
|
|
|
}},
|
|
|
|
|
},
|
|
|
|
|
Stream: true,
|
|
|
|
|
}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
messages, _ := m["messages"].([]any)
|
|
|
|
|
msg, _ := messages[0].(map[string]any)
|
|
|
|
|
content, ok := msg["content"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("content type = %T, want string", msg["content"])
|
|
|
|
|
}
|
|
|
|
|
if content != "first part second part" {
|
|
|
|
|
t.Fatalf("content = %q, want %q", content, "first part second part")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMarshalForUpstream_PreservesNonTextArrayContent verifies that content
|
|
|
|
|
// arrays containing non-text parts (e.g. images) are left intact rather than
|
|
|
|
|
// being flattened, so the upstream can handle multimodal content.
|
|
|
|
|
func TestMarshalForUpstream_PreservesNonTextArrayContent(t *testing.T) {
|
|
|
|
|
req := ChatCompletionRequest{
|
|
|
|
|
Model: "GLM-4.7",
|
|
|
|
|
Messages: []map[string]any{
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "describe this"},
|
|
|
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,abc"}},
|
|
|
|
|
}},
|
|
|
|
|
},
|
|
|
|
|
Stream: true,
|
|
|
|
|
}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
messages, _ := m["messages"].([]any)
|
|
|
|
|
msg, _ := messages[0].(map[string]any)
|
|
|
|
|
parts, ok := msg["content"].([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("content type = %T, want []any (preserved array)", msg["content"])
|
|
|
|
|
}
|
|
|
|
|
if len(parts) != 2 {
|
|
|
|
|
t.Fatalf("parts = %d, want 2", len(parts))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMarshalForUpstream_ExtraFields verifies that extra fields (max_tokens,
|
|
|
|
|
// tools, etc.) are passed through to the upstream body.
|
|
|
|
|
func TestMarshalForUpstream_ExtraFields(t *testing.T) {
|
|
|
|
|
req := ChatCompletionRequest{
|
|
|
|
|
Model: "GLM-4.7",
|
|
|
|
|
Stream: true,
|
|
|
|
|
Extra: map[string]json.RawMessage{
|
|
|
|
|
"max_tokens": json.RawMessage(`32000`),
|
|
|
|
|
"tool_choice": json.RawMessage(`"auto"`),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if v, _ := m["max_tokens"].(float64); v != 32000 {
|
|
|
|
|
t.Fatalf("max_tokens = %v, want 32000", m["max_tokens"])
|
|
|
|
|
}
|
|
|
|
|
if v, _ := m["tool_choice"].(string); v != "auto" {
|
|
|
|
|
t.Fatalf("tool_choice = %v, want \"auto\"", m["tool_choice"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMarshalForUpstream_GLMToolStream verifies that tool_stream is added
|
|
|
|
|
// for GLM models but not for non-GLM models.
|
|
|
|
|
func TestMarshalForUpstream_GLMToolStream(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
model string
|
|
|
|
|
wantTool bool
|
|
|
|
|
}{
|
|
|
|
|
{"GLM-4.7", true},
|
|
|
|
|
{"zhanlu/glm-4-pro", true},
|
|
|
|
|
{"zhanlu/deepseek-v4-pro", false},
|
|
|
|
|
{"deepseek-chat", false},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
req := ChatCompletionRequest{Model: tc.model, Stream: true}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
_ = json.Unmarshal(body, &m)
|
|
|
|
|
_, has := m["tool_stream"]
|
|
|
|
|
if has != tc.wantTool {
|
|
|
|
|
t.Fatalf("model %q: tool_stream present = %v, want %v", tc.model, has, tc.wantTool)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_MixedContent verifies a mix of string and array
|
|
|
|
|
// content across multiple messages in a single request.
|
|
|
|
|
func TestNormalizeMessages_MixedContent(t *testing.T) {
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "system", "content": "you are helpful"},
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "part 1 "},
|
|
|
|
|
map[string]any{"type": "text", "text": "part 2"},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "assistant", "content": "ok"},
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "only part"},
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, nil)
|
|
|
|
|
// system message unchanged
|
|
|
|
|
if c, _ := result[0]["content"].(string); c != "you are helpful" {
|
|
|
|
|
t.Fatalf("msg[0] content = %v", result[0]["content"])
|
|
|
|
|
}
|
|
|
|
|
// array content flattened
|
|
|
|
|
if c, _ := result[1]["content"].(string); c != "part 1 part 2" {
|
|
|
|
|
t.Fatalf("msg[1] content = %v, want %q", result[1]["content"], "part 1 part 2")
|
|
|
|
|
}
|
|
|
|
|
// string content unchanged
|
|
|
|
|
if c, _ := result[2]["content"].(string); c != "ok" {
|
|
|
|
|
t.Fatalf("msg[2] content = %v", result[2]["content"])
|
|
|
|
|
}
|
|
|
|
|
// single-part array flattened to string
|
|
|
|
|
if c, _ := result[3]["content"].(string); c != "only part" {
|
|
|
|
|
t.Fatalf("msg[3] content = %v, want %q", result[3]["content"], "only part")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_EmptyArray verifies that an empty content array
|
|
|
|
|
// becomes an empty string.
|
|
|
|
|
func TestNormalizeMessages_EmptyArray(t *testing.T) {
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": []any{}},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, nil)
|
|
|
|
|
if c, _ := result[0]["content"].(string); c != "" {
|
|
|
|
|
t.Fatalf("content = %v, want empty string", result[0]["content"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_MapSliceContent verifies that content stored as
|
|
|
|
|
// []map[string]any (produced by convertContentParts in the Responses API
|
|
|
|
|
// path) is also flattened, not just []any (from JSON decoding).
|
|
|
|
|
func TestNormalizeMessages_MapSliceContent(t *testing.T) {
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": []map[string]any{
|
|
|
|
|
{"type": "text", "text": "map part 1 "},
|
|
|
|
|
{"type": "text", "text": "map part 2"},
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, nil)
|
|
|
|
|
if c, _ := result[0]["content"].(string); c != "map part 1 map part 2" {
|
|
|
|
|
t.Fatalf("content = %v, want %q", result[0]["content"], "map part 1 map part 2")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMarshalForUpstream_ErrorRequestReplay is a regression test mirroring
|
|
|
|
|
// the real failing request from error.md: a multi-turn conversation with
|
|
|
|
|
// system prompt, user messages with multi-part content arrays, assistant
|
|
|
|
|
// messages with tool_calls and reasoning_content, and tool result messages.
|
|
|
|
|
// It verifies that no message has array content after marshaling.
|
|
|
|
|
func TestMarshalForUpstream_ErrorRequestReplay(t *testing.T) {
|
|
|
|
|
req := ChatCompletionRequest{
|
|
|
|
|
Model: "zhanlu/deepseek-v4-pro",
|
|
|
|
|
Messages: []map[string]any{
|
|
|
|
|
{"role": "system", "content": strings.Repeat("system prompt ", 100)},
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "user question"},
|
|
|
|
|
map[string]any{"type": "text", "text": "<system-reminder>skill search</system-reminder>"},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "assistant", "content": "I will search.", "reasoning_content": "thinking...", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "file1\nfile2"},
|
|
|
|
|
{"role": "user", "content": []any{
|
|
|
|
|
map[string]any{"type": "text", "text": "continue"},
|
|
|
|
|
}},
|
|
|
|
|
},
|
|
|
|
|
Stream: true,
|
|
|
|
|
Extra: map[string]json.RawMessage{
|
|
|
|
|
"max_tokens": json.RawMessage(`32000`),
|
|
|
|
|
"tool_choice": json.RawMessage(`"auto"`),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
body, err := req.MarshalForUpstream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
messages, _ := m["messages"].([]any)
|
|
|
|
|
for i, raw := range messages {
|
|
|
|
|
msg, _ := raw.(map[string]any)
|
|
|
|
|
switch c := msg["content"].(type) {
|
|
|
|
|
case string:
|
|
|
|
|
// OK — flattened
|
|
|
|
|
case []any:
|
|
|
|
|
t.Fatalf("message [%d] still has array content after marshal", i)
|
|
|
|
|
default:
|
|
|
|
|
t.Fatalf("message [%d] content type = %T", i, c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_SanitizesInvalidToolCallNames verifies that
|
|
|
|
|
// tool_calls whose function name is not in the declared tools set are
|
|
|
|
|
// fixed in place — the name is replaced with a valid one — rather than
|
|
|
|
|
// removed. This preserves the conversation context including error
|
|
|
|
|
// feedback. MiMoCode emits {name:"invalid"} placeholders when a tool
|
|
|
|
|
// call fails; the Zhanlu gateway rejects unknown function names with
|
|
|
|
|
// HTTP 400.
|
|
|
|
|
func TestNormalizeMessages_SanitizesInvalidToolCallNames(t *testing.T) {
|
|
|
|
|
validNames := map[string]bool{"bash": true, "read": true}
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{"role": "assistant", "content": "I'll run bash.", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_ok", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
|
|
|
|
|
// "invalid" name with "tool" field in args — should be extracted
|
|
|
|
|
map[string]any{"id": "call_bad", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"read","error":"failed"}`}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_ok", "content": "file1"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_bad", "content": "error result"},
|
|
|
|
|
{"role": "user", "content": "thanks"},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, validNames)
|
|
|
|
|
// All messages preserved (no removal)
|
|
|
|
|
if len(result) != 5 {
|
|
|
|
|
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
|
|
|
|
|
}
|
|
|
|
|
// Both tool_calls kept; the invalid one's name should be fixed to "read"
|
|
|
|
|
// (extracted from args.tool)
|
|
|
|
|
asst := result[1]
|
|
|
|
|
tcs, _ := asst["tool_calls"].([]any)
|
|
|
|
|
if len(tcs) != 2 {
|
|
|
|
|
t.Fatalf("tool_calls = %d items, want 2 (preserved)", len(tcs))
|
|
|
|
|
}
|
|
|
|
|
tc1, _ := tcs[0].(map[string]any)
|
|
|
|
|
fn1, _ := tc1["function"].(map[string]any)
|
|
|
|
|
if name, _ := fn1["name"].(string); name != "bash" {
|
|
|
|
|
t.Fatalf("first tool_call name = %q, want %q", name, "bash")
|
|
|
|
|
}
|
|
|
|
|
tc2, _ := tcs[1].(map[string]any)
|
|
|
|
|
fn2, _ := tc2["function"].(map[string]any)
|
|
|
|
|
if name, _ := fn2["name"].(string); name != "read" {
|
|
|
|
|
t.Fatalf("sanitized tool_call name = %q, want %q", name, "read")
|
|
|
|
|
}
|
|
|
|
|
// Tool results preserved
|
|
|
|
|
toolMsg := result[3]
|
|
|
|
|
if id, _ := toolMsg["tool_call_id"].(string); id != "call_bad" {
|
|
|
|
|
t.Fatalf("tool_call_id = %q, want %q", id, "call_bad")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_SanitizesInvalidArguments verifies that tool_calls
|
|
|
|
|
// whose arguments are not a valid JSON object string (e.g. "-1", "",
|
|
|
|
|
// "true") are fixed to "{}" in place, not removed. The Zhanlu gateway
|
|
|
|
|
// requires arguments to be a JSON object.
|
|
|
|
|
func TestNormalizeMessages_SanitizesInvalidArguments(t *testing.T) {
|
|
|
|
|
validNames := map[string]bool{"task": true}
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": "create tasks"},
|
|
|
|
|
{"role": "assistant", "content": "creating.", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_good", "type": "function", "function": map[string]any{"name": "task", "arguments": `{"operation":"create","summary":"test"}`}},
|
|
|
|
|
map[string]any{"id": "call_bad1", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
|
|
|
|
|
map[string]any{"id": "call_bad2", "type": "function", "function": map[string]any{"name": "task", "arguments": ""}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_good", "content": "created"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_bad1", "content": "error"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_bad2", "content": "error"},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, validNames)
|
|
|
|
|
// All 5 messages preserved
|
|
|
|
|
if len(result) != 5 {
|
|
|
|
|
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
|
|
|
|
|
}
|
|
|
|
|
asst := result[1]
|
|
|
|
|
tcs, _ := asst["tool_calls"].([]any)
|
|
|
|
|
if len(tcs) != 3 {
|
|
|
|
|
t.Fatalf("tool_calls = %d items, want 3 (preserved)", len(tcs))
|
|
|
|
|
}
|
|
|
|
|
// Good args unchanged
|
|
|
|
|
tc0, _ := tcs[0].(map[string]any)
|
|
|
|
|
fn0, _ := tc0["function"].(map[string]any)
|
|
|
|
|
if args, _ := fn0["arguments"].(string); args != `{"operation":"create","summary":"test"}` {
|
|
|
|
|
t.Fatalf("good args changed: %q", args)
|
|
|
|
|
}
|
|
|
|
|
// Bad args fixed to "{}"
|
|
|
|
|
tc1, _ := tcs[1].(map[string]any)
|
|
|
|
|
fn1, _ := tc1["function"].(map[string]any)
|
|
|
|
|
if args, _ := fn1["arguments"].(string); args != "{}" {
|
|
|
|
|
t.Fatalf("bad1 args = %q, want {}", args)
|
|
|
|
|
}
|
|
|
|
|
tc2, _ := tcs[2].(map[string]any)
|
|
|
|
|
fn2, _ := tc2["function"].(map[string]any)
|
|
|
|
|
if args, _ := fn2["arguments"].(string); args != "{}" {
|
|
|
|
|
t.Fatalf("bad2 args = %q, want {}", args)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_KeepsValidToolCalls verifies that valid tool_calls
|
|
|
|
|
// (correct name and valid JSON object arguments) are preserved unchanged.
|
|
|
|
|
func TestNormalizeMessages_KeepsValidToolCalls(t *testing.T) {
|
|
|
|
|
validNames := map[string]bool{"bash": true, "read": true}
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": "list files"},
|
|
|
|
|
{"role": "assistant", "content": "sure.", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
|
|
|
|
|
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "read", "arguments": `{"file":"a.txt"}`}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "file1"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_2", "content": "content"},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, validNames)
|
|
|
|
|
if len(result) != 4 {
|
|
|
|
|
t.Fatalf("messages = %d, want 4", len(result))
|
|
|
|
|
}
|
|
|
|
|
asst := result[1]
|
|
|
|
|
tcs, _ := asst["tool_calls"].([]any)
|
|
|
|
|
if len(tcs) != 2 {
|
|
|
|
|
t.Fatalf("tool_calls = %d items, want 2", len(tcs))
|
|
|
|
|
}
|
|
|
|
|
// Verify the first tool_call is unchanged
|
|
|
|
|
tc, _ := tcs[0].(map[string]any)
|
|
|
|
|
fn, _ := tc["function"].(map[string]any)
|
|
|
|
|
if name, _ := fn["name"].(string); name != "bash" {
|
|
|
|
|
t.Fatalf("name = %q, want %q", name, "bash")
|
|
|
|
|
}
|
|
|
|
|
if args, _ := fn["arguments"].(string); args != `{"command":"ls"}` {
|
|
|
|
|
t.Fatalf("args = %q, want %q", args, `{"command":"ls"}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_NoToolNamesSkipsNameCheck verifies that when no
|
|
|
|
|
// tools are declared (empty map), tool_calls are not name-sanitized
|
|
|
|
|
// (only argument validity is checked).
|
|
|
|
|
func TestNormalizeMessages_NoToolNamesSkipsNameCheck(t *testing.T) {
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
{"role": "assistant", "content": "ok.", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "custom_fn", "arguments": `{"x":1}`}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, nil)
|
|
|
|
|
if len(result) != 3 {
|
|
|
|
|
t.Fatalf("messages = %d, want 3", len(result))
|
|
|
|
|
}
|
|
|
|
|
asst := result[1]
|
|
|
|
|
tcs, _ := asst["tool_calls"].([]any)
|
|
|
|
|
tc, _ := tcs[0].(map[string]any)
|
|
|
|
|
fn, _ := tc["function"].(map[string]any)
|
|
|
|
|
// Name should be unchanged (no tools to validate against)
|
|
|
|
|
if name, _ := fn["name"].(string); name != "custom_fn" {
|
|
|
|
|
t.Fatalf("name = %q, want %q (should not be changed)", name, "custom_fn")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNormalizeMessages_ErrorMdScenario mirrors the exact error.md
|
|
|
|
|
// request: an assistant message with 5 tool_calls where 4 have
|
|
|
|
|
// name:"invalid" (with args containing {"tool":"task","error":"…"}) and 1
|
|
|
|
|
// has name:"task" with args:"-1". Verifies that after sanitization, all
|
|
|
|
|
// 5 calls have valid names and valid JSON object arguments.
|
|
|
|
|
func TestNormalizeMessages_ErrorMdScenario(t *testing.T) {
|
|
|
|
|
validNames := map[string]bool{"task": true, "bash": true}
|
|
|
|
|
messages := []map[string]any{
|
|
|
|
|
{"role": "user", "content": "create tasks"},
|
|
|
|
|
{"role": "assistant", "content": "confirming.", "tool_calls": []any{
|
|
|
|
|
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
|
|
|
|
|
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
|
|
|
|
|
map[string]any{"id": "call_3", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
|
|
|
|
|
map[string]any{"id": "call_4", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
|
|
|
|
|
map[string]any{"id": "call_5", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
|
|
|
|
|
}},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "error 1"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_2", "content": "error 2"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_3", "content": "error 3"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_4", "content": "error 4"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_5", "content": "error 5"},
|
|
|
|
|
}
|
|
|
|
|
result := normalizeMessages(messages, validNames)
|
|
|
|
|
// All 7 messages preserved (no removal)
|
|
|
|
|
if len(result) != 7 {
|
|
|
|
|
t.Fatalf("messages = %d, want 7", len(result))
|
|
|
|
|
}
|
|
|
|
|
asst := result[1]
|
|
|
|
|
tcs, _ := asst["tool_calls"].([]any)
|
|
|
|
|
if len(tcs) != 5 {
|
|
|
|
|
t.Fatalf("tool_calls = %d items, want 5", len(tcs))
|
|
|
|
|
}
|
|
|
|
|
for i, raw := range tcs {
|
|
|
|
|
tc, _ := raw.(map[string]any)
|
|
|
|
|
fn, _ := tc["function"].(map[string]any)
|
|
|
|
|
name, _ := fn["name"].(string)
|
|
|
|
|
args, _ := fn["arguments"].(string)
|
|
|
|
|
// All names should be "task" (extracted from args or already valid)
|
|
|
|
|
if name != "task" {
|
|
|
|
|
t.Errorf("tool_call[%d] name = %q, want %q", i, name, "task")
|
|
|
|
|
}
|
|
|
|
|
// All args should be valid JSON objects
|
|
|
|
|
if !isValidJSONObject(args) {
|
|
|
|
|
t.Errorf("tool_call[%d] args = %q, not a valid JSON object", i, args)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestIsValidJSONObject verifies the JSON object validation used to filter
|
|
|
|
|
// tool_call arguments.
|
|
|
|
|
func TestIsValidJSONObject(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
input string
|
|
|
|
|
want bool
|
|
|
|
|
}{
|
|
|
|
|
{`{"key":"value"}`, true},
|
|
|
|
|
{`{}`, true},
|
|
|
|
|
{`{"nested":{"a":1}}`, true},
|
|
|
|
|
{`-1`, false},
|
|
|
|
|
{`true`, false},
|
|
|
|
|
{`"string"`, false},
|
|
|
|
|
{`[1,2,3]`, false},
|
|
|
|
|
{``, false},
|
|
|
|
|
{` `, false},
|
|
|
|
|
{`{invalid json}`, false},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
if got := isValidJSONObject(tc.input); got != tc.want {
|
|
|
|
|
t.Errorf("isValidJSONObject(%q) = %v, want %v", tc.input, got, tc.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|