gateway_tool_rewrite_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. "github.com/tidwall/gjson"
  10. )
  11. func TestBuildDynamicToolMap_BelowThreshold(t *testing.T) {
  12. // Parrot 行为:tools 数量 ≤ 5 时不做动态映射。
  13. names := []string{"bash", "edit", "read", "write", "search"}
  14. require.Nil(t, buildDynamicToolMap(names))
  15. }
  16. func TestBuildDynamicToolMap_AboveThresholdIsStable(t *testing.T) {
  17. // Parrot 不变量:同一组 tool_names 在同进程内映射稳定(保证 cache 命中)。
  18. names := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"}
  19. a := buildDynamicToolMap(names)
  20. b := buildDynamicToolMap(names)
  21. require.NotNil(t, a)
  22. require.Equal(t, a, b, "same input tool_names must yield identical mapping")
  23. require.Len(t, a, 6)
  24. for _, name := range names {
  25. require.Contains(t, a, name)
  26. require.NotEqual(t, name, a[name])
  27. }
  28. }
  29. func TestSanitizeToolName_StaticPrefix(t *testing.T) {
  30. require.Equal(t, "cc_sess_list", sanitizeToolName("sessions_list", nil))
  31. require.Equal(t, "cc_ses_get", sanitizeToolName("session_get", nil))
  32. require.Equal(t, "bash", sanitizeToolName("bash", nil))
  33. }
  34. func TestSanitizeToolName_DynamicTakesPrecedence(t *testing.T) {
  35. dyn := map[string]string{"sessions_list": "analyze_ses00"}
  36. got := sanitizeToolName("sessions_list", dyn)
  37. require.Equal(t, "analyze_ses00", got, "dynamic mapping wins over static prefix")
  38. }
  39. func TestRestoreToolNamesInBytes_LongestFirst(t *testing.T) {
  40. // 当假名 "abc_12" 是另一个更长假名的子串(真实场景极少但算法必须防御)时,
  41. // 长的必须先替换。本测试用显式构造的映射来验证排序不变量。
  42. rw := &ToolNameRewrite{
  43. Forward: map[string]string{"foo": "abc_12", "bar": "abc_12_ext"},
  44. Reverse: map[string]string{"abc_12": "foo", "abc_12_ext": "bar"},
  45. }
  46. // 手工构造 ReverseOrdered:长的在前
  47. rw.ReverseOrdered = [][2]string{
  48. {"abc_12_ext", "bar"},
  49. {"abc_12", "foo"},
  50. }
  51. data := []byte(`{"tool":"abc_12_ext","other":"abc_12"}`)
  52. restored := string(restoreToolNamesInBytes(data, rw))
  53. require.Equal(t, `{"tool":"bar","other":"foo"}`, restored)
  54. }
  55. func TestRestoreToolNamesInBytes_StaticPrefixRollback(t *testing.T) {
  56. data := []byte(`{"name":"sessions_list","id":"cc_ses_xyz"}`)
  57. got := string(restoreToolNamesInBytes(data, nil))
  58. require.Equal(t, `{"name":"sessions_list","id":"session_xyz"}`, got)
  59. }
  60. func TestApplyToolNameRewriteToBody_RenamesToolsAndToolChoice(t *testing.T) {
  61. body := []byte(`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"session_get","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"tool_choice":{"type":"tool","name":"sessions_list"}}`)
  62. rw := buildToolNameRewriteFromBody(body)
  63. require.NotNil(t, rw)
  64. require.Contains(t, rw.Forward, "sessions_list")
  65. require.Contains(t, rw.Forward, "session_get")
  66. // web_search 是 server tool,不参与工具名改写
  67. require.NotContains(t, rw.Forward, "web_search")
  68. out := applyToolNameRewriteToBody(body, rw)
  69. // tools[0].name 和 tools[1].name 被改写,tools[2].name 保持不变
  70. require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tools.0.name").String())
  71. require.Equal(t, "cc_ses_get", gjson.GetBytes(out, "tools.1.name").String())
  72. require.Equal(t, "web_search", gjson.GetBytes(out, "tools.2.name").String())
  73. // tool_choice.name 被同步改写
  74. require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tool_choice.name").String())
  75. require.Equal(t, "tool", gjson.GetBytes(out, "tool_choice.type").String())
  76. }
  77. func TestApplyToolNameRewriteToBody_RenamesToolUseInMessages(t *testing.T) {
  78. // sessions_list 通过静态前缀规则改写为 cc_sess_list
  79. // web_search 是 server tool(type != ""),不参与工具名改写
  80. // messages 中的 tool_use.name 必须同步改写,才能和 tools[] 保持一致
  81. body := []byte(`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"assistant","content":[{"type":"tool_use","id":"tu_01","name":"sessions_list","input":{}},{"type":"text","text":"thinking"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_01","content":"ok"}]}]}`)
  82. rw := buildToolNameRewriteFromBody(body)
  83. require.NotNil(t, rw)
  84. require.Equal(t, "cc_sess_list", rw.Forward["sessions_list"])
  85. out := applyToolNameRewriteToBody(body, rw)
  86. // tools[0].name 被改写
  87. require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tools.0.name").String())
  88. // tools[1].name 是 server tool,保持不变
  89. require.Equal(t, "web_search", gjson.GetBytes(out, "tools.1.name").String())
  90. // messages[1].content[0].name 是 tool_use,必须同步改写以匹配 tools[]
  91. require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "messages.1.content.0.name").String())
  92. // messages[1].content[1] 是 text,保持不变
  93. require.Equal(t, "thinking", gjson.GetBytes(out, "messages.1.content.1.text").String())
  94. // messages[2].content[0] 是 tool_result,不包含 name 字段,保持不变
  95. require.Equal(t, "ok", gjson.GetBytes(out, "messages.2.content.0.content").String())
  96. }
  97. func TestApplyToolNameRewriteToBody_RenamesToolUseWithDynamicMapping(t *testing.T) {
  98. body := []byte(`{"tools":[{"name":"alpha_search","input_schema":{}},{"name":"beta_lookup","input_schema":{}},{"name":"gamma_fetch","input_schema":{}},{"name":"delta_update","input_schema":{}},{"name":"epsilon_parse","input_schema":{}},{"name":"zeta_render","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"tool_choice":{"type":"tool","name":"gamma_fetch"},"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"tu_dyn","name":"gamma_fetch","input":{}},{"type":"tool_use","id":"tu_srv","name":"web_search","input":{}},{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_dyn","content":"ok"}]}]}`)
  99. rw := buildToolNameRewriteFromBody(body)
  100. require.NotNil(t, rw)
  101. require.Len(t, rw.Forward, 6)
  102. fakeGamma := rw.Forward["gamma_fetch"]
  103. require.NotEmpty(t, fakeGamma)
  104. require.NotEqual(t, "gamma_fetch", fakeGamma)
  105. require.NotContains(t, rw.Forward, "web_search")
  106. out := applyToolNameRewriteToBody(body, rw)
  107. // 动态映射会改写 tools[]、tool_choice 和历史 tool_use 中的同一个工具名
  108. require.Equal(t, fakeGamma, gjson.GetBytes(out, "tools.2.name").String())
  109. require.Equal(t, fakeGamma, gjson.GetBytes(out, "tool_choice.name").String())
  110. require.Equal(t, fakeGamma, gjson.GetBytes(out, "messages.0.content.0.name").String())
  111. // server tool 不参与动态映射,历史 tool_use 中同名引用也保持不变
  112. require.Equal(t, "web_search", gjson.GetBytes(out, "tools.6.name").String())
  113. require.Equal(t, "web_search", gjson.GetBytes(out, "messages.0.content.1.name").String())
  114. // tool_result 依靠 tool_use_id 关联,不需要 name 字段
  115. require.Equal(t, "ok", gjson.GetBytes(out, "messages.1.content.0.content").String())
  116. }
  117. func TestApplyToolsLastCacheBreakpoint_InjectsDefault(t *testing.T) {
  118. body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","input_schema":{}}]}`)
  119. out := applyToolsLastCacheBreakpoint(body)
  120. require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.1.cache_control.type").String())
  121. require.Equal(t, "5m", gjson.GetBytes(out, "tools.1.cache_control.ttl").String())
  122. // First tool untouched
  123. require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists())
  124. }
  125. func TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL(t *testing.T) {
  126. body := []byte(`{"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral","ttl":"1h"}}]}`)
  127. out := applyToolsLastCacheBreakpoint(body)
  128. // User-provided ttl must be preserved.
  129. require.Equal(t, "1h", gjson.GetBytes(out, "tools.0.cache_control.ttl").String())
  130. }
  131. func TestApplyToolsLastCacheBreakpoint_StripsDeferredToolCacheControl(t *testing.T) {
  132. body := []byte(`{"tools":[{"name":"a","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral","ttl":"1h"}},{"name":"b","custom":{"defer_loading":true}}]}`)
  133. out := applyToolsLastCacheBreakpoint(body)
  134. require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists())
  135. require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists())
  136. }
  137. func TestApplyToolsLastCacheBreakpoint_SkipsDeferredFinalTool(t *testing.T) {
  138. body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","defer_loading":true}]}`)
  139. out := applyToolsLastCacheBreakpoint(body)
  140. require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.0.cache_control.type").String())
  141. require.Equal(t, "5m", gjson.GetBytes(out, "tools.0.cache_control.ttl").String())
  142. require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists())
  143. }
  144. func TestApplyToolsLastCacheBreakpoint_OnlyLiteralTrueIsDeferred(t *testing.T) {
  145. body := []byte(`{"tools":[{"name":"custom-true","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-true","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"false","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"string","defer_loading":"true","cache_control":{"type":"ephemeral"}},{"name":"number","defer_loading":1,"cache_control":{"type":"ephemeral"}},{"name":"object","defer_loading":{},"cache_control":{"type":"ephemeral"}}]}`)
  146. out := stripDeferredToolCacheControl(body)
  147. require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists())
  148. require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists())
  149. for idx := 2; idx < 6; idx++ {
  150. require.Equal(t, "ephemeral", gjson.GetBytes(out, fmt.Sprintf("tools.%d.cache_control.type", idx)).String())
  151. }
  152. }
  153. func TestStripMessageCacheControl(t *testing.T) {
  154. body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`)
  155. out := stripMessageCacheControl(body)
  156. require.False(t, gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists())
  157. }
  158. func TestAddMessageCacheBreakpoints_LastMessageOnly(t *testing.T) {
  159. body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  160. out := addMessageCacheBreakpoints(body)
  161. require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
  162. require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
  163. }
  164. func TestAddMessageCacheBreakpoints_SecondToLastUserTurn(t *testing.T) {
  165. // Parrot 不变量:messages ≥ 4 时才打第二个断点,且位置是"倒数第二个 user turn"。
  166. body := []byte(`{"messages":[
  167. {"role":"user","content":[{"type":"text","text":"q1"}]},
  168. {"role":"assistant","content":[{"type":"text","text":"a1"}]},
  169. {"role":"user","content":[{"type":"text","text":"q2"}]},
  170. {"role":"assistant","content":[{"type":"text","text":"a2"}]}
  171. ]}`)
  172. out := addMessageCacheBreakpoints(body)
  173. // 最后一条 assistant 被打断点
  174. require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.3.content.0.cache_control.type").String())
  175. // 倒数第二个 user turn = index 0(唯一另一个 user)
  176. require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
  177. // 其他不打断点
  178. require.False(t, gjson.GetBytes(out, "messages.1.content.0.cache_control").Exists())
  179. require.False(t, gjson.GetBytes(out, "messages.2.content.0.cache_control").Exists())
  180. }
  181. func TestAddMessageCacheBreakpoints_StringContentPromoted(t *testing.T) {
  182. body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
  183. out := addMessageCacheBreakpoints(body)
  184. // content 升级成数组
  185. require.True(t, gjson.GetBytes(out, "messages.0.content").IsArray())
  186. require.Equal(t, "text", gjson.GetBytes(out, "messages.0.content.0.type").String())
  187. require.Equal(t, "hi", gjson.GetBytes(out, "messages.0.content.0.text").String())
  188. require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
  189. }
  190. func TestRewriteMessageCacheControlIfEnabled_DefaultKeepsClientAnchors(t *testing.T) {
  191. body := []byte(`{"messages":[
  192. {"role":"user","content":[{"type":"text","text":"stable","cache_control":{"type":"ephemeral","ttl":"1h"}}]},
  193. {"role":"assistant","content":[{"type":"text","text":"ok"}]},
  194. {"role":"user","content":[{"type":"text","text":"latest","cache_control":{"type":"ephemeral","ttl":"5m"}}]}
  195. ]}`)
  196. out := (&GatewayService{}).rewriteMessageCacheControlIfEnabled(context.Background(), body)
  197. require.JSONEq(t, string(body), string(out))
  198. require.Equal(t, "1h", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
  199. require.Equal(t, "5m", gjson.GetBytes(out, "messages.2.content.0.cache_control.ttl").String())
  200. }
  201. func TestRewriteMessageCacheControlIfEnabled_OptInPreservesLegacyRewrite(t *testing.T) {
  202. body := []byte(`{"messages":[
  203. {"role":"user","content":[{"type":"text","text":"stable","cache_control":{"type":"ephemeral","ttl":"1h"}}]},
  204. {"role":"assistant","content":[{"type":"text","text":"ok"}]},
  205. {"role":"user","content":[{"type":"text","text":"latest","cache_control":{"type":"ephemeral","ttl":"1h"}}]},
  206. {"role":"assistant","content":[{"type":"text","text":"done"}]}
  207. ]}`)
  208. repo := &gatewayTTLSettingRepo{data: map[string]string{
  209. SettingKeyRewriteMessageCacheControl: "true",
  210. }}
  211. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
  212. svc := &GatewayService{settingService: NewSettingService(repo, &config.Config{})}
  213. out := svc.rewriteMessageCacheControlIfEnabled(context.Background(), body)
  214. require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
  215. require.False(t, gjson.GetBytes(out, "messages.2.content.0.cache_control").Exists())
  216. require.Equal(t, "5m", gjson.GetBytes(out, "messages.3.content.0.cache_control.ttl").String())
  217. }
  218. func TestBuildToolNameRewriteFromBody_ReverseOrderedByLengthDesc(t *testing.T) {
  219. // 超过阈值触发动态映射,验证 ReverseOrdered 按假名长度倒序排列
  220. body := []byte(`{"tools":[
  221. {"name":"t1","input_schema":{}},
  222. {"name":"t2","input_schema":{}},
  223. {"name":"t3","input_schema":{}},
  224. {"name":"t4","input_schema":{}},
  225. {"name":"t5","input_schema":{}},
  226. {"name":"t6","input_schema":{}}
  227. ]}`)
  228. rw := buildToolNameRewriteFromBody(body)
  229. require.NotNil(t, rw)
  230. require.NotEmpty(t, rw.ReverseOrdered)
  231. for i := 1; i < len(rw.ReverseOrdered); i++ {
  232. require.GreaterOrEqual(t, len(rw.ReverseOrdered[i-1][0]), len(rw.ReverseOrdered[i][0]),
  233. "ReverseOrdered must be sorted by fake-name length descending")
  234. }
  235. }
  236. func TestRestoreToolNamesInBytes_NoMapping_NoStaticMatch_IsNoop(t *testing.T) {
  237. data := []byte("plain text without any tool names")
  238. require.Equal(t, string(data), string(restoreToolNamesInBytes(data, nil)))
  239. }
  240. // Ensure the fake name format follows Parrot's "{prefix}{name[:3]}{i:02d}".
  241. func TestBuildDynamicToolMap_FakeNameShape(t *testing.T) {
  242. names := []string{"alphabet", "bravo", "charlie", "delta", "echo", "foxtrot"}
  243. m := buildDynamicToolMap(names)
  244. require.NotNil(t, m)
  245. for _, name := range names {
  246. fake, ok := m[name]
  247. require.True(t, ok)
  248. // fake = prefix + head3 + "%02d"
  249. // ends with two decimal digits
  250. require.Regexp(t, `^[a-z]+_[a-z0-9]{1,3}\d{2}$`, fake)
  251. head := name
  252. if len(head) > 3 {
  253. head = head[:3]
  254. }
  255. require.True(t, strings.Contains(fake, head), "fake %q should contain head3 %q of %q", fake, head, name)
  256. }
  257. }