mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-03 07:55:27 +08:00
Compare commits
5 Commits
v2.4.1_bet
...
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586d31e556 | ||
|
|
c4a73e871a | ||
|
|
25b3292497 | ||
|
|
11f66db87d | ||
|
|
7131b06e26 |
@@ -358,40 +358,6 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
|
||||
h := &Handler{}
|
||||
payload := "I'll call a tool now.\\n<tool_use><tool_name>write_file</tool_name><parameters>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</parameters></tool_use>"
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"`+payload+`"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"})
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
contentBlock, _ := f.Payload["content_block"].(map[string]any)
|
||||
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "write_file" {
|
||||
foundToolUse = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundToolUse {
|
||||
t.Fatalf("expected tool_use block with leading prose payload, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
for _, f := range findClaudeFrames(frames, "message_delta") {
|
||||
delta, _ := f.Payload["delta"].(map[string]any)
|
||||
if delta["stop_reason"] == "tool_use" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
|
||||
@@ -38,9 +38,6 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
|
||||
}
|
||||
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
|
||||
toolNames := extractClaudeToolNames(toolsRequested)
|
||||
if len(toolNames) == 0 && len(toolsRequested) > 0 {
|
||||
toolNames = []string{"__any_tool__"}
|
||||
}
|
||||
|
||||
return claudeNormalizedRequest{
|
||||
Standard: util.StandardRequest{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"ds2api/internal/sse"
|
||||
streamengine "ds2api/internal/stream"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type claudeStreamRuntime struct {
|
||||
@@ -119,6 +120,15 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
if hasUnclosedCodeFence(s.text.String()) {
|
||||
continue
|
||||
}
|
||||
detected := util.ParseToolCalls(s.text.String(), s.toolNames)
|
||||
if len(detected) > 0 {
|
||||
s.finalize("tool_use")
|
||||
return streamengine.ParsedDecision{
|
||||
ContentSeen: true,
|
||||
Stop: true,
|
||||
StopReason: streamengine.StopReason("tool_use_detected"),
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.closeThinkingBlock()
|
||||
|
||||
@@ -45,9 +45,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
|
||||
finalText := s.text.String()
|
||||
|
||||
if s.bufferToolContent {
|
||||
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
|
||||
detected := util.ParseToolCalls(finalText, s.toolNames)
|
||||
if len(detected) == 0 && finalText == "" && finalThinking != "" {
|
||||
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
|
||||
detected = util.ParseToolCalls(finalThinking, s.toolNames)
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
stopReason = "tool_use"
|
||||
|
||||
@@ -111,21 +111,28 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := namesToSet(allowedNames)
|
||||
if len(allowed) == 0 {
|
||||
for _, d := range deltas {
|
||||
if d.Name != "" {
|
||||
seenNames[d.Index] = "__blocked__"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]toolCallDelta, 0, len(deltas))
|
||||
for _, d := range deltas {
|
||||
if d.Name != "" {
|
||||
if seenNames != nil {
|
||||
seenNames[d.Index] = d.Name
|
||||
if _, ok := allowed[d.Name]; !ok {
|
||||
seenNames[d.Index] = "__blocked__"
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
if seenNames == nil {
|
||||
seenNames[d.Index] = d.Name
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(seenNames[d.Index])
|
||||
if name == "" {
|
||||
if name == "" || name == "__blocked__" {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) {
|
||||
func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
@@ -198,13 +198,16 @@ func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) {
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
if choice["finish_reason"] != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"])
|
||||
}
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
toolCalls, _ := msg["tool_calls"].([]any)
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected tool_calls for unknown schema name, got %#v", msg["tool_calls"])
|
||||
if _, ok := msg["tool_calls"]; ok {
|
||||
t.Fatalf("did not expect tool_calls for unknown schema name, got %#v", msg["tool_calls"])
|
||||
}
|
||||
content, _ := msg["content"].(string)
|
||||
if !strings.Contains(content, `"tool_calls"`) {
|
||||
t.Fatalf("expected unknown tool json to pass through as text, got %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +413,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) {
|
||||
func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
@@ -425,18 +428,18 @@ func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) {
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta for unknown schema name, body=%s", rec.Body.String())
|
||||
if streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
if streamFinishReason(frames) != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) {
|
||||
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
|
||||
@@ -451,14 +454,14 @@ func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) {
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
|
||||
if streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
if streamFinishReason(frames) != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/prompt"
|
||||
@@ -55,13 +56,45 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
|
||||
}
|
||||
|
||||
func buildAssistantContentForPrompt(msg map[string]any) string {
|
||||
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
|
||||
content := normalizeOpenAIContentForPrompt(msg["content"])
|
||||
toolCalls := normalizeAssistantToolCallsForPrompt(msg["tool_calls"])
|
||||
if toolCalls == "" {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return toolCalls
|
||||
}
|
||||
return strings.TrimSpace(content + "\n" + toolCalls)
|
||||
}
|
||||
|
||||
func normalizeAssistantToolCallsForPrompt(v any) string {
|
||||
calls, ok := v.([]any)
|
||||
if !ok || len(calls) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(calls)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", calls))
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func buildToolContentForPrompt(msg map[string]any) string {
|
||||
content := normalizeOpenAIContentForPrompt(msg["content"])
|
||||
payload := map[string]any{
|
||||
"content": msg["content"],
|
||||
}
|
||||
if id := strings.TrimSpace(asString(msg["tool_call_id"])); id != "" {
|
||||
payload["tool_call_id"] = id
|
||||
}
|
||||
if id := strings.TrimSpace(asString(msg["id"])); id != "" {
|
||||
payload["id"] = id
|
||||
}
|
||||
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
content := normalizeOpenAIContentForPrompt(payload)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "null"
|
||||
return `{"content":"null"}`
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 3 {
|
||||
t.Fatalf("expected 3 normalized messages with tool-call-only assistant turn omitted, got %d", len(normalized))
|
||||
if len(normalized) != 4 {
|
||||
t.Fatalf("expected 4 normalized messages with assistant tool_call history preserved, got %d", len(normalized))
|
||||
}
|
||||
toolContent, _ := normalized[2]["content"].(string)
|
||||
if !strings.Contains(toolContent, `"temp":18`) {
|
||||
toolContent, _ := normalized[3]["content"].(string)
|
||||
if !strings.Contains(toolContent, `\"temp\":18`) {
|
||||
t.Fatalf("tool result should be transparently forwarded, got %q", toolContent)
|
||||
}
|
||||
if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") {
|
||||
@@ -87,8 +87,8 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) {
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, `line-1`) || !strings.Contains(got, `line-2`) {
|
||||
t.Fatalf("expected tool content blocks preserved, got %q", got)
|
||||
if !strings.Contains(got, `"line-1"`) || !strings.Contains(got, `"line-2"`) || !strings.Contains(got, `"name":"read_file"`) {
|
||||
t.Fatalf("expected tool envelope to preserve content blocks and metadata, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) {
|
||||
t.Fatalf("expected function role normalized as tool, got %#v", normalized[0]["role"])
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, `"ok":true`) || strings.Contains(got, `"name":"legacy_tool"`) {
|
||||
if !strings.Contains(got, `"name":"legacy_tool"`) || !strings.Contains(got, `"ok":true`) {
|
||||
t.Fatalf("unexpected normalized function-role content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,8 @@ func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *tes
|
||||
t.Fatalf("expected tool role preserved, got %#v", normalized[0]["role"])
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if got != "null" {
|
||||
t.Fatalf("expected empty tool content normalized as null string, got %q", got)
|
||||
if !strings.Contains(got, `"content":""`) || !strings.Contains(got, `"name":"noop_tool"`) || !strings.Contains(got, `"tool_call_id":"call_5"`) {
|
||||
t.Fatalf("expected tool metadata preserved in content envelope, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +170,12 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 0 {
|
||||
t.Fatalf("expected assistant tool_call-only message omitted, got %#v", normalized)
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected assistant tool_call-only message to be preserved, got %#v", normalized)
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, `"name":"search_web"`) || !strings.Contains(got, `"name":"eval_javascript"`) {
|
||||
t.Fatalf("expected tool_calls payload preserved in assistant content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +196,12 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 0 {
|
||||
t.Fatalf("expected assistant tool_call-only content omitted, got %#v", normalized)
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected assistant tool_call-only content to be preserved, got %#v", normalized)
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, `{}{\"query\":\"测试工具调用\"}`) {
|
||||
t.Fatalf("expected concatenated arguments preserved verbatim, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +222,12 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 0 {
|
||||
t.Fatalf("expected assistant tool_calls without text omitted, got %#v", normalized)
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected assistant tool_calls history to be preserved even when name missing, got %#v", normalized)
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, "call_missing_name") {
|
||||
t.Fatalf("expected raw tool_call payload preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,8 +249,12 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 0 {
|
||||
t.Fatalf("expected nil-content assistant tool_call-only message omitted, got %#v", normalized)
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected nil-content assistant tool_call-only message to be preserved, got %#v", normalized)
|
||||
}
|
||||
got, _ := normalized[0]["content"].(string)
|
||||
if !strings.Contains(got, "send_file_to_user") {
|
||||
t.Fatalf("expected tool call payload preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
|
||||
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -376,8 +376,8 @@ func TestHandleResponsesStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T
|
||||
|
||||
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, policy, "")
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "event: response.function_call_arguments.done") {
|
||||
t.Fatalf("expected function_call events for tool_choice=none, body=%s", body)
|
||||
if strings.Contains(body, "event: response.function_call_arguments.done") {
|
||||
t.Fatalf("did not expect function_call events for tool_choice=none, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) {
|
||||
func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -539,8 +539,8 @@ func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) {
|
||||
|
||||
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "event: response.function_call_arguments.done") {
|
||||
t.Fatalf("expected function_call events for unknown tool, body=%s", body)
|
||||
if strings.Contains(body, "event: response.function_call_arguments.done") {
|
||||
t.Fatalf("did not expect function_call events for unknown tool, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,7 +597,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
|
||||
func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
rec := httptest.NewRecorder()
|
||||
resp := &http.Response{
|
||||
@@ -611,20 +611,16 @@ func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testin
|
||||
|
||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, policy, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for tool_choice=none handling, got %d body=%s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 200 for tool_choice=none passthrough text, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
output, _ := out["output"].([]any)
|
||||
foundFunctionCall := false
|
||||
for _, item := range output {
|
||||
m, _ := item.(map[string]any)
|
||||
if m != nil && m["type"] == "function_call" {
|
||||
foundFunctionCall = true
|
||||
t.Fatalf("did not expect function_call output item for tool_choice=none, got %#v", output)
|
||||
}
|
||||
}
|
||||
if !foundFunctionCall {
|
||||
t.Fatalf("expected function_call output item for tool_choice=none, got %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
|
||||
|
||||
@@ -25,7 +25,6 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
|
||||
}
|
||||
toolPolicy := util.DefaultToolChoicePolicy()
|
||||
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
|
||||
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
|
||||
passThrough := collectOpenAIChatPassThrough(req)
|
||||
|
||||
return util.StandardRequest{
|
||||
@@ -75,8 +74,10 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
|
||||
return util.StandardRequest{}, err
|
||||
}
|
||||
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
|
||||
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
|
||||
if !toolPolicy.IsNone() {
|
||||
if toolPolicy.IsNone() {
|
||||
toolNames = nil
|
||||
toolPolicy.Allowed = nil
|
||||
} else {
|
||||
toolPolicy.Allowed = namesToSet(toolNames)
|
||||
}
|
||||
passThrough := collectOpenAIChatPassThrough(req)
|
||||
@@ -97,20 +98,6 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureToolDetectionEnabled(toolNames []string, toolsRaw any) []string {
|
||||
if len(toolNames) > 0 {
|
||||
return toolNames
|
||||
}
|
||||
tools, _ := toolsRaw.([]any)
|
||||
if len(tools) == 0 {
|
||||
return toolNames
|
||||
}
|
||||
// Keep stream sieve/tool buffering enabled even when client tool schemas
|
||||
// are malformed or lack explicit names; parsed tool payload names are no
|
||||
// longer filtered by this list.
|
||||
return []string{"__any_tool__"}
|
||||
}
|
||||
|
||||
func collectOpenAIChatPassThrough(req map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, k := range []string{
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceForcedUndeclaredFails(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(t *testing.T) {
|
||||
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T) {
|
||||
store := newEmptyStoreForNormalizeTest(t)
|
||||
req := map[string]any{
|
||||
"model": "gpt-4o",
|
||||
@@ -174,7 +174,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(
|
||||
if n.ToolChoice.Mode != util.ToolChoiceNone {
|
||||
t.Fatalf("expected tool choice mode none, got %q", n.ToolChoice.Mode)
|
||||
}
|
||||
if len(n.ToolNames) == 0 {
|
||||
t.Fatalf("expected tool detection sentinel when tool_choice=none, got %#v", n.ToolNames)
|
||||
if len(n.ToolNames) != 0 {
|
||||
t.Fatalf("expected no tool names when tool_choice=none, got %#v", n.ToolNames)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
|
||||
var leakedToolHistoryPattern = regexp.MustCompile(`(?is)\[TOOL_CALL_HISTORY\][\s\S]*?\[/TOOL_CALL_HISTORY\]|\[TOOL_RESULT_HISTORY\][\s\S]*?\[/TOOL_RESULT_HISTORY\]`)
|
||||
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
|
||||
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
|
||||
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
|
||||
var leakedMetaMarkerPattern = regexp.MustCompile(`(?is)<\s*\|\s*(?:assistant|tool|end_of_sentence|end_of_thinking)\s*\|\s*>`)
|
||||
|
||||
func sanitizeLeakedToolHistory(text string) string {
|
||||
if text == "" {
|
||||
@@ -16,8 +13,5 @@ func sanitizeLeakedToolHistory(text string) string {
|
||||
}
|
||||
out := leakedToolHistoryPattern.ReplaceAllString(text, "")
|
||||
out = emptyJSONFencePattern.ReplaceAllString(out, "")
|
||||
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
|
||||
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
|
||||
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -77,22 +77,6 @@ func TestFlushToolSieveDropsToolResultHistoryLeak(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedToolHistoryRemovesLeakedWireToolCallAndResult(t *testing.T) {
|
||||
raw := "开始\n[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"java -version\\\"}\",\"name\":\"exec\"},\"id\":\"callb9a321\",\"type\":\"function\"}]< | Tool | >{\"content\":\"openjdk version 21\",\"tool_call_id\":\"callb9a321\"}\n结束"
|
||||
got := sanitizeLeakedToolHistory(raw)
|
||||
if got != "开始\n\n结束" {
|
||||
t.Fatalf("unexpected sanitize result for leaked wire format: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedToolHistoryRemovesStandaloneMetaMarkers(t *testing.T) {
|
||||
raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C"
|
||||
got := sanitizeLeakedToolHistory(raw)
|
||||
if got != "ABC" {
|
||||
t.Fatalf("unexpected sanitize result for meta markers: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessToolSieveChunkSplitsResultHistoryBoundary(t *testing.T) {
|
||||
var state toolStreamSieveState
|
||||
parts := []string{
|
||||
|
||||
@@ -167,7 +167,7 @@ func findToolSegmentStart(s string) int {
|
||||
return -1
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"}
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
|
||||
bestKeyIdx := -1
|
||||
for _, kw := range keywords {
|
||||
idx := strings.Index(lower, kw)
|
||||
@@ -195,7 +195,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
}
|
||||
lower := strings.ToLower(captured)
|
||||
keyIdx := -1
|
||||
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"}
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
|
||||
for _, kw := range keywords {
|
||||
idx := strings.Index(lower, kw)
|
||||
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
|
||||
|
||||
@@ -8,10 +8,7 @@ const {
|
||||
|
||||
function resolveToolcallPolicy(prepBody, payloadTools) {
|
||||
const preparedToolNames = normalizePreparedToolNames(prepBody && prepBody.tool_names);
|
||||
let toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
|
||||
if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) {
|
||||
toolNames = ['__any_tool__'];
|
||||
}
|
||||
const toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
|
||||
const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match);
|
||||
const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
|
||||
return {
|
||||
@@ -79,6 +76,17 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
|
||||
return [];
|
||||
}
|
||||
const seen = seenNames instanceof Map ? seenNames : new Map();
|
||||
const allowed = new Set((allowedNames || []).filter((name) => asString(name) !== ''));
|
||||
if (allowed.size === 0) {
|
||||
for (const d of deltas) {
|
||||
if (d && typeof d === 'object' && asString(d.name)) {
|
||||
const index = Number.isInteger(d.index) ? d.index : 0;
|
||||
seen.set(index, '__blocked__');
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const d of deltas) {
|
||||
if (!d || typeof d !== 'object') {
|
||||
@@ -87,12 +95,16 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
|
||||
const index = Number.isInteger(d.index) ? d.index : 0;
|
||||
const name = asString(d.name);
|
||||
if (name) {
|
||||
if (!allowed.has(name)) {
|
||||
seen.set(index, '__blocked__');
|
||||
continue;
|
||||
}
|
||||
seen.set(index, name);
|
||||
out.push(d);
|
||||
continue;
|
||||
}
|
||||
const existing = asString(seen.get(index));
|
||||
if (!existing) {
|
||||
if (!existing || existing === '__blocked__') {
|
||||
continue;
|
||||
}
|
||||
out.push(d);
|
||||
|
||||
@@ -140,17 +140,63 @@ function emptyParseResult() {
|
||||
}
|
||||
|
||||
function filterToolCallsDetailed(parsed, toolNames) {
|
||||
const sourceNames = Array.isArray(toolNames) ? toolNames : [];
|
||||
const allowed = new Set();
|
||||
const allowedCanonical = new Map();
|
||||
for (const item of sourceNames) {
|
||||
const name = toStringSafe(item);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
allowed.add(name);
|
||||
const lower = name.toLowerCase();
|
||||
if (!allowedCanonical.has(lower)) {
|
||||
allowedCanonical.set(lower, name);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed.size === 0) {
|
||||
const rejected = [];
|
||||
const seen = new Set();
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(tc.name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(tc.name);
|
||||
rejected.push(tc.name);
|
||||
}
|
||||
return { calls: [], rejectedToolNames: rejected };
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
const rejected = [];
|
||||
const seenRejected = new Set();
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
let matchedName = '';
|
||||
if (allowed.has(tc.name)) {
|
||||
matchedName = tc.name;
|
||||
} else {
|
||||
matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical);
|
||||
}
|
||||
if (!matchedName) {
|
||||
if (!seenRejected.has(tc.name)) {
|
||||
seenRejected.add(tc.name);
|
||||
rejected.push(tc.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
calls.push({
|
||||
name: tc.name,
|
||||
name: matchedName,
|
||||
input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {},
|
||||
});
|
||||
}
|
||||
return { calls, rejectedToolNames: [] };
|
||||
return { calls, rejectedToolNames: rejected };
|
||||
}
|
||||
|
||||
function resolveAllowedToolName(name, allowed, allowedCanonical) {
|
||||
|
||||
@@ -56,11 +56,6 @@ function buildToolCallCandidates(text) {
|
||||
if (first >= 0 && last > first) {
|
||||
candidates.push(toStringSafe(trimmed.slice(first, last + 1)));
|
||||
}
|
||||
const firstArr = trimmed.indexOf('[');
|
||||
const lastArr = trimmed.lastIndexOf(']');
|
||||
if (firstArr >= 0 && lastArr > firstArr) {
|
||||
candidates.push(toStringSafe(trimmed.slice(firstArr, lastArr + 1)));
|
||||
}
|
||||
|
||||
const m = trimmed.match(TOOL_CALL_PATTERN);
|
||||
if (m && m[1]) {
|
||||
@@ -81,17 +76,7 @@ function extractToolCallObjects(text) {
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const idxToolCalls = lower.indexOf('tool_calls', offset);
|
||||
const idxFunction = lower.indexOf('"function"', offset);
|
||||
let idx = -1;
|
||||
let matched = '';
|
||||
if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) {
|
||||
idx = idxToolCalls;
|
||||
matched = 'tool_calls';
|
||||
} else if (idxFunction >= 0) {
|
||||
idx = idxFunction;
|
||||
matched = '"function"';
|
||||
}
|
||||
let idx = lower.indexOf('tool_calls', offset);
|
||||
if (idx < 0) {
|
||||
break;
|
||||
}
|
||||
@@ -107,7 +92,7 @@ function extractToolCallObjects(text) {
|
||||
start = raw.slice(0, start).lastIndexOf('{');
|
||||
}
|
||||
if (idx >= 0) {
|
||||
offset = idx + matched.length;
|
||||
offset = idx + 'tool_calls'.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
const TOOL_SEGMENT_KEYWORDS = [
|
||||
'tool_calls',
|
||||
'"function"',
|
||||
'function.name:',
|
||||
'[tool_call_history]',
|
||||
'[tool_result_history]',
|
||||
|
||||
@@ -30,12 +30,6 @@ func buildToolCallCandidates(text string) []string {
|
||||
if first >= 0 && last > first {
|
||||
candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1]))
|
||||
}
|
||||
// best-effort array slice: from first '[' to last ']'
|
||||
firstArr := strings.Index(trimmed, "[")
|
||||
lastArr := strings.LastIndex(trimmed, "]")
|
||||
if firstArr >= 0 && lastArr > firstArr {
|
||||
candidates = append(candidates, strings.TrimSpace(trimmed[firstArr:lastArr+1]))
|
||||
}
|
||||
|
||||
// legacy regex extraction fallback
|
||||
if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 {
|
||||
@@ -64,7 +58,7 @@ func extractToolCallObjects(text string) []string {
|
||||
lower := strings.ToLower(text)
|
||||
out := []string{}
|
||||
offset := 0
|
||||
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]"}
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
|
||||
for {
|
||||
bestIdx := -1
|
||||
matchedKeyword := ""
|
||||
|
||||
@@ -16,7 +16,6 @@ type ToolCallParseResult struct {
|
||||
RejectedByPolicy bool
|
||||
RejectedToolNames []string
|
||||
}
|
||||
|
||||
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
return ParseToolCallsDetailed(text, availableToolNames).Calls
|
||||
}
|
||||
@@ -120,17 +119,56 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
|
||||
}
|
||||
|
||||
func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) {
|
||||
allowed := map[string]struct{}{}
|
||||
allowedCanonical := map[string]string{}
|
||||
for _, name := range availableToolNames {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
allowed[trimmed] = struct{}{}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if _, exists := allowedCanonical[lower]; !exists {
|
||||
allowedCanonical[lower] = trimmed
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
rejectedSet := map[string]struct{}{}
|
||||
rejected := make([]string, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := rejectedSet[tc.Name]; ok {
|
||||
continue
|
||||
}
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
rejected = append(rejected, tc.Name)
|
||||
}
|
||||
return nil, rejected
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(parsed))
|
||||
rejectedSet := map[string]struct{}{}
|
||||
rejected := make([]string, 0)
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical)
|
||||
if matchedName == "" {
|
||||
if _, ok := rejectedSet[tc.Name]; !ok {
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
rejected = append(rejected, tc.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
tc.Name = matchedName
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out, nil
|
||||
return out, rejected
|
||||
}
|
||||
|
||||
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||
@@ -190,10 +228,8 @@ func isLikelyChatMessageEnvelope(v map[string]any) bool {
|
||||
func looksLikeToolCallSyntax(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
return strings.Contains(lower, "tool_calls") ||
|
||||
strings.Contains(lower, "\"function\"") ||
|
||||
strings.Contains(lower, "<tool_call") ||
|
||||
strings.Contains(lower, "<function_call") ||
|
||||
strings.Contains(lower, "<function_name") ||
|
||||
strings.Contains(lower, "<invoke") ||
|
||||
strings.Contains(lower, "function.name:")
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ var antmlParametersPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?parameter
|
||||
var invokeCallPattern = regexp.MustCompile(`(?is)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>`)
|
||||
var invokeParamPattern = regexp.MustCompile(`(?is)<parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</parameter>`)
|
||||
var toolUseFunctionPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function\s+name="([^"]+)"\s*>(.*?)</function>\s*</tool_use>`)
|
||||
var toolUseNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
|
||||
var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function_name>\s*([^<]+?)\s*</function_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
|
||||
var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*(.*?)\s*</tool_use>`)
|
||||
|
||||
func parseXMLToolCalls(text string) []ParsedToolCall {
|
||||
matches := xmlToolCallPattern.FindAllString(text, -1)
|
||||
@@ -45,15 +42,6 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
|
||||
if call, ok := parseToolUseFunctionStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
if call, ok := parseToolUseNameParametersStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
if call, ok := parseToolUseFunctionNameParametersStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
if call, ok := parseToolUseToolNameBodyStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -269,104 +257,6 @@ func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) {
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) {
|
||||
m := toolUseNameParametersPattern.FindStringSubmatch(text)
|
||||
if len(m) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
raw := strings.TrimSpace(m[2])
|
||||
input := map[string]any{}
|
||||
if raw != "" {
|
||||
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
|
||||
input = parsed
|
||||
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
|
||||
input = kv
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) {
|
||||
m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text)
|
||||
if len(m) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
raw := strings.TrimSpace(m[2])
|
||||
input := map[string]any{}
|
||||
if raw != "" {
|
||||
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
|
||||
input = parsed
|
||||
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
|
||||
input = kv
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) {
|
||||
m := toolUseToolNameBodyPattern.FindStringSubmatch(text)
|
||||
if len(m) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
body := strings.TrimSpace(m[2])
|
||||
input := map[string]any{}
|
||||
if body != "" {
|
||||
if kv := parseXMLChildKV(body); len(kv) > 0 {
|
||||
input = kv
|
||||
} else if kv := parseMarkupKVObject(body); len(kv) > 0 {
|
||||
input = kv
|
||||
} else if parsed := parseToolCallInput(body); len(parsed) > 0 {
|
||||
input = parsed
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseXMLChildKV(body string) map[string]any {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
dec := xml.NewDecoder(strings.NewReader("<root>" + trimmed + "</root>"))
|
||||
out := map[string]any{}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok || strings.EqualFold(start.Name.Local, "root") {
|
||||
continue
|
||||
}
|
||||
var v string
|
||||
if err := dec.DecodeElement(&v, &start); err != nil {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(start.Name.Local)
|
||||
val := strings.TrimSpace(v)
|
||||
if key == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = val
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
|
||||
@@ -41,50 +41,50 @@ func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsKeepsUnknownToolName(t *testing.T) {
|
||||
func TestParseToolCallsRejectsUnknownToolName(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 || calls[0].Name != "unknown" {
|
||||
t.Fatalf("expected unknown tool to be preserved, got %#v", calls)
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected unknown tool to be rejected, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsKeepsOriginalToolNameCase(t *testing.T) {
|
||||
func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsDetailedDoesNotRejectByPolicy(t *testing.T) {
|
||||
func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
|
||||
res := ParseToolCallsDetailed(text, []string{"search"})
|
||||
if !res.SawToolCallSyntax {
|
||||
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
|
||||
}
|
||||
if res.RejectedByPolicy {
|
||||
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
|
||||
if !res.RejectedByPolicy {
|
||||
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
|
||||
}
|
||||
if len(res.Calls) != 1 || res.Calls[0].Name != "unknown" {
|
||||
t.Fatalf("expected call to be preserved, got %#v", res.Calls)
|
||||
if len(res.Calls) != 0 {
|
||||
t.Fatalf("expected no calls after policy rejection, got %#v", res.Calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsDetailedAllowsWhenAllowListEmpty(t *testing.T) {
|
||||
func TestParseToolCallsDetailedRejectsWhenAllowListEmpty(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
res := ParseToolCallsDetailed(text, nil)
|
||||
if !res.SawToolCallSyntax {
|
||||
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
|
||||
}
|
||||
if res.RejectedByPolicy {
|
||||
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
|
||||
if !res.RejectedByPolicy {
|
||||
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
|
||||
}
|
||||
if len(res.Calls) != 1 || res.Calls[0].Name != "search" {
|
||||
t.Fatalf("expected calls when allow-list is empty, got %#v", res.Calls)
|
||||
if len(res.Calls) != 0 {
|
||||
t.Fatalf("expected no calls when allow-list is empty, got %#v", res.Calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +132,8 @@ func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "mcp.search_web" {
|
||||
t.Fatalf("expected original tool name mcp.search_web, got %q", calls[0].Name)
|
||||
if calls[0].Name != "search_web" {
|
||||
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "read-file" {
|
||||
t.Fatalf("expected original tool name read-file, got %q", calls[0].Name)
|
||||
if calls[0].Name != "read_file" {
|
||||
t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,8 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -179,8 +179,8 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -193,8 +193,8 @@ func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "ls -la" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -207,8 +207,8 @@ func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -221,8 +221,8 @@ func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -235,8 +235,8 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -257,56 +257,14 @@ func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) {
|
||||
text := `<tool_use><tool_name>write_file</tool_name><parameters>{"path":"/tmp/a.txt","content":"abc"}</parameters></tool_use>`
|
||||
calls := ParseToolCalls(text, []string{"write_file"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "write_file" {
|
||||
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["path"] != "/tmp/a.txt" {
|
||||
t.Fatalf("expected path argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) {
|
||||
text := `<tool_use><function_name>write_file</function_name><parameters>{"path":"/tmp/b.txt","content":"xyz"}</parameters></tool_use>`
|
||||
calls := ParseToolCalls(text, []string{"write_file"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "write_file" {
|
||||
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["content"] != "xyz" {
|
||||
t.Fatalf("expected content argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) {
|
||||
text := `<tool_use><tool_name>write_file</tool_name><path>/tmp/c.txt</path><content>hello</content></tool_use>`
|
||||
calls := ParseToolCalls(text, []string{"write_file"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "write_file" {
|
||||
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["path"] != "/tmp/c.txt" {
|
||||
t.Fatalf("expected path argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) {
|
||||
text := `<tool_call><tool name="Bash"><command>pwd</command><description>show cwd</description></tool></tool_call>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -319,8 +277,8 @@ func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testin
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" {
|
||||
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
@@ -333,8 +291,8 @@ func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) {
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" || calls[1].Name != "Read" {
|
||||
t.Fatalf("expected original names [Bash Read], got %#v", calls)
|
||||
if calls[0].Name != "bash" || calls[1].Name != "read" {
|
||||
t.Fatalf("expected canonical names [bash read], got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,8 +364,8 @@ func TestFormatOpenAIStreamToolCalls(t *testing.T) {
|
||||
func TestParseToolCallsNoToolNames(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
calls := ParseToolCalls(text, nil)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected 0 call with nil tool names, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "unknown_tool",
|
||||
"input": {
|
||||
"x": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"calls": [],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
"rejectedByPolicy": true,
|
||||
"rejectedToolNames": [
|
||||
"unknown_tool"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "Read_File",
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
@@ -10,4 +10,4 @@
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "read-file",
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
@@ -10,4 +10,4 @@
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "company.fs.read_file",
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
@@ -10,4 +10,4 @@
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "unknown_tool",
|
||||
"input": {
|
||||
"x": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"calls": [],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
"rejectedByPolicy": true,
|
||||
"rejectedToolNames": [
|
||||
"unknown_tool"
|
||||
]
|
||||
}
|
||||
@@ -58,7 +58,7 @@ test('boolDefaultTrue keeps false only when explicitly false', () => {
|
||||
assert.equal(boolDefaultTrue(undefined), true);
|
||||
});
|
||||
|
||||
test('filterIncrementalToolCallDeltasByAllowed keeps unknown name and follow-up args', () => {
|
||||
test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up args', () => {
|
||||
const seen = new Map();
|
||||
const filtered = filterIncrementalToolCallDeltasByAllowed(
|
||||
[
|
||||
@@ -68,11 +68,8 @@ test('filterIncrementalToolCallDeltasByAllowed keeps unknown name and follow-up
|
||||
['read_file'],
|
||||
seen,
|
||||
);
|
||||
assert.deepEqual(filtered, [
|
||||
{ index: 0, name: 'not_in_schema' },
|
||||
{ index: 0, arguments: '{"x":1}' },
|
||||
]);
|
||||
assert.equal(seen.get(0), 'not_in_schema');
|
||||
assert.deepEqual(filtered, []);
|
||||
assert.equal(seen.get(0), '__blocked__');
|
||||
});
|
||||
|
||||
test('filterIncrementalToolCallDeltasByAllowed keeps allowed name and args', () => {
|
||||
|
||||
@@ -55,34 +55,33 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseToolCalls keeps unknown schema names when toolNames is provided', () => {
|
||||
test('parseToolCalls drops unknown schema names when toolNames is provided', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
|
||||
});
|
||||
const calls = parseToolCalls(payload, ['search']);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].name, 'not_in_schema');
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('parseToolCalls keeps original tool name casing', () => {
|
||||
test('parseToolCalls matches tool name case-insensitively and canonicalizes', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_calls: [{ name: 'Read_File', input: { path: 'README.MD' } }],
|
||||
});
|
||||
const calls = parseToolCalls(payload, ['read_file']);
|
||||
assert.deepEqual(calls, [{ name: 'Read_File', input: { path: 'README.MD' } }]);
|
||||
assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.MD' } }]);
|
||||
});
|
||||
|
||||
test('parseToolCalls accepts all names when toolNames is empty', () => {
|
||||
test('parseToolCalls rejects all names when toolNames is empty (Go strict parity)', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
|
||||
});
|
||||
const calls = parseToolCalls(payload, []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls.length, 0);
|
||||
|
||||
const detailed = parseToolCallsDetailed(payload, []);
|
||||
assert.equal(detailed.sawToolCallSyntax, true);
|
||||
assert.equal(detailed.rejectedByPolicy, false);
|
||||
assert.deepEqual(detailed.rejectedToolNames, []);
|
||||
assert.equal(detailed.rejectedByPolicy, true);
|
||||
assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']);
|
||||
});
|
||||
|
||||
test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => {
|
||||
@@ -288,7 +287,7 @@ test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () =>
|
||||
assert.equal(leakedText, 'Hello world');
|
||||
});
|
||||
|
||||
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
|
||||
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
|
||||
const events = runSieve(
|
||||
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
|
||||
['read_file'],
|
||||
@@ -296,7 +295,8 @@ test('sieve emits unknown tool payload (no args) as executable tool call', () =>
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
|
||||
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
|
||||
assert.equal(hasToolCall || hasToolDelta, true);
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(hasToolDelta, false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
assert.equal(leakedText.includes('后置正文G。'), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user