openai_gateway_cc_tool_call_identity_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. //go:build unit
  2. package service
  3. import (
  4. "strings"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. )
  9. // TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched 首包带合法
  10. // id/name 的 delta 必须原样保留(changed=false)。
  11. func TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched(t *testing.T) {
  12. payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`)
  13. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  14. require.False(t, changed)
  15. require.Equal(t, string(payload), string(rewritten))
  16. require.Equal(t, "call_example", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
  17. require.Equal(t, "web_search", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
  18. // 首包 arguments 为空串也不应被删除。
  19. require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
  20. require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  21. }
  22. // TestStripEmptyChatToolCallIdentity_FollowUpDelta 后续参数 delta 的
  23. // `"id":""` 与 `"function":{"name":""}` 应被删除;arguments 碎片、
  24. // index、type 保留。
  25. func TestStripEmptyChatToolCallIdentity_FollowingDelta(t *testing.T) {
  26. payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`)
  27. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  28. require.True(t, changed)
  29. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
  30. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
  31. require.Equal(t, `{"query":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  32. require.Equal(t, int64(0), gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.index").Int())
  33. require.Equal(t, "function", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.type").String())
  34. // 空串字段不得再出现在 payload 里。
  35. require.NotContains(t, string(rewritten), `"id":""`)
  36. require.NotContains(t, string(rewritten), `"name":""`)
  37. }
  38. // TestStripEmptyChatToolCallIdentity_OnlyEmptyName / _OnlyEmptyID 只删
  39. // 空的那一个,非空字段必须保留。
  40. func TestStripEmptyChatToolCallIdentity_OnlyEmptyName(t *testing.T) {
  41. payload := []byte(`{"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`)
  42. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  43. require.True(t, changed)
  44. require.Equal(t, "call_1", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
  45. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
  46. require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  47. }
  48. // TestStripEmptyChatToolCallIdentity_OnlyEmptyID 覆盖 `"id": ""` 带空格的
  49. // JSON 形式,确认 gjson/sjson 均能识别。
  50. func TestStripEmptyChatToolCallIdentity_OnlyEmptyID(t *testing.T) {
  51. payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id" : "" , "type" : "function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`)
  52. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  53. require.True(t, changed)
  54. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
  55. require.Equal(t, "get_weather", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
  56. require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  57. }
  58. // TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept 空 arguments 不删,
  59. // 只有 id/name 空串被剔除。
  60. func TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept(t *testing.T) {
  61. payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}]}}]}`)
  62. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  63. require.True(t, changed)
  64. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
  65. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
  66. require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
  67. require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  68. }
  69. // TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls 两个并行 index 都要
  70. // 处理:合法的 index 0 保留,后续参数 delta 的 index 1 剔除空 id/name。
  71. func TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls(t *testing.T) {
  72. payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"tool_a","arguments":"{\"x\":"}},{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"y\":"}}]}}]}`)
  73. rewritten, changed := stripEmptyChatToolCallIdentity(payload)
  74. require.True(t, changed)
  75. require.Equal(t, "call_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
  76. require.Equal(t, "tool_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
  77. require.Equal(t, `{"x":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
  78. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.id").Exists())
  79. require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.name").Exists())
  80. require.Equal(t, `{"y":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.arguments").String())
  81. }
  82. // TestStripEmptyChatToolCallIdentity_Passthrough 无 tool_calls、无 choices、
  83. // 非数组 tool_calls、非法 JSON 一律原样返回。
  84. func TestStripEmptyChatToolCallIdentity_Passthrough(t *testing.T) {
  85. tests := []struct {
  86. name string
  87. payload string
  88. }{
  89. {"no tool_calls", `{"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
  90. {"no choices", `{"id":"chatcmpl_x"}`},
  91. {"empty choices", `{"choices":[]}`},
  92. {"tool_calls not array", `{"choices":[{"index":0,"delta":{"tool_calls":{"foo":1}}}]}`},
  93. {"invalid JSON", `{"choices":[{`},
  94. {"empty string", ``},
  95. }
  96. for _, tt := range tests {
  97. t.Run(tt.name, func(t *testing.T) {
  98. rewritten, changed := stripEmptyChatToolCallIdentity([]byte(tt.payload))
  99. require.False(t, changed)
  100. require.Equal(t, tt.payload, string(rewritten))
  101. })
  102. }
  103. }
  104. // TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough SSE 行级:非
  105. // data 行、[DONE]、空行原样;data 行保留 `data: ` 前缀。
  106. func TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough(t *testing.T) {
  107. tests := []struct {
  108. name string
  109. line string
  110. }{
  111. {"done", "data: [DONE]"},
  112. {"non-data line", ": keep-alive"},
  113. {"empty line", ""},
  114. {"comment line", ":"},
  115. {"content chunk", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
  116. }
  117. for _, tt := range tests {
  118. t.Run(tt.name, func(t *testing.T) {
  119. require.Equal(t, tt.line, stripEmptyChatToolCallIdentityFromSSELine(tt.line))
  120. })
  121. }
  122. }
  123. // TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix 改写后的
  124. // SSE 行必须保留 `data: ` 前缀。
  125. func TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix(t *testing.T) {
  126. line := `data: {"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`
  127. got := stripEmptyChatToolCallIdentityFromSSELine(line)
  128. require.True(t, strings.HasPrefix(got, "data: "))
  129. payload, ok := extractOpenAISSEDataLine(got)
  130. require.True(t, ok)
  131. require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.id").Exists())
  132. require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.function.name").Exists())
  133. require.Equal(t, "{}", gjson.Get(payload, "choices.0.delta.tool_calls.0.function.arguments").String())
  134. }
  135. // TestStripEmptyChatToolCallIdentity_DshClientMerge 模拟 dsh rc.2 adapter 的
  136. // 合并逻辑(字段存在——含空串——才覆盖):sanitize 后合并,最终 id/name 必须
  137. // 仍是首包合法值,arguments 为各碎片拼接。
  138. func TestStripEmptyChatToolCallIdentity_DshClientMerge(t *testing.T) {
  139. lines := []string{
  140. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
  141. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
  142. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
  143. }
  144. var mergedID, mergedName, mergedArgs string
  145. for _, line := range lines {
  146. sanitized := stripEmptyChatToolCallIdentityFromSSELine(line)
  147. payload, ok := extractOpenAISSEDataLine(sanitized)
  148. require.True(t, ok)
  149. for _, tc := range gjson.Get(payload, "choices.0.delta.tool_calls").Array() {
  150. if v := tc.Get("id"); v.Exists() {
  151. mergedID = v.String()
  152. }
  153. if v := tc.Get("function.name"); v.Exists() {
  154. mergedName = v.String()
  155. }
  156. if v := tc.Get("function.arguments"); v.Exists() {
  157. mergedArgs += v.String()
  158. }
  159. }
  160. }
  161. require.Equal(t, "call_example", mergedID)
  162. require.Equal(t, "web_search", mergedName)
  163. require.Equal(t, `{"query":"example"}`, mergedArgs)
  164. }