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