generate_session_hash_test.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. //go:build unit
  2. package service
  3. import (
  4. "encoding/json"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/domain"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func mustParseSessionHashRequest(t *testing.T, body string, ctx *SessionContext) *ParsedRequest {
  10. t.Helper()
  11. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(body)), domain.PlatformAnthropic)
  12. require.NoError(t, err)
  13. parsed.SessionContext = ctx
  14. return parsed
  15. }
  16. func mustParseGeminiSessionHashRequest(t *testing.T, body string, ctx *SessionContext) *ParsedRequest {
  17. t.Helper()
  18. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(body)), domain.PlatformGemini)
  19. require.NoError(t, err)
  20. parsed.SessionContext = ctx
  21. return parsed
  22. }
  23. func mustParseResponsesSessionHashRequest(t *testing.T, body string, ctx *SessionContext) *ParsedRequest {
  24. t.Helper()
  25. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(body)), "responses")
  26. require.NoError(t, err)
  27. parsed.SessionContext = ctx
  28. return parsed
  29. }
  30. func anthropicSessionBody(system any, messages []any, metadataUserID string) string {
  31. body := map[string]any{}
  32. if system != nil {
  33. body["system"] = system
  34. }
  35. if messages != nil {
  36. body["messages"] = messages
  37. }
  38. if metadataUserID != "" {
  39. body["metadata"] = map[string]any{"user_id": metadataUserID}
  40. }
  41. data, _ := json.Marshal(body)
  42. return string(data)
  43. }
  44. func geminiSessionBody(systemParts []any, contents []any) string {
  45. body := map[string]any{}
  46. if systemParts != nil {
  47. body["systemInstruction"] = map[string]any{"parts": systemParts}
  48. }
  49. if contents != nil {
  50. body["contents"] = contents
  51. }
  52. data, _ := json.Marshal(body)
  53. return string(data)
  54. }
  55. func msg(role string, content any) map[string]any {
  56. return map[string]any{"role": role, "content": content}
  57. }
  58. func geminiMsg(role string, texts ...string) map[string]any {
  59. parts := make([]any, 0, len(texts))
  60. for _, text := range texts {
  61. parts = append(parts, map[string]any{"text": text})
  62. }
  63. return map[string]any{"role": role, "parts": parts}
  64. }
  65. func TestGenerateSessionHash_NilParsedRequest(t *testing.T) {
  66. svc := &GatewayService{}
  67. require.Empty(t, svc.GenerateSessionHash(nil))
  68. }
  69. func TestGenerateSessionHash_EmptyRequest(t *testing.T) {
  70. svc := &GatewayService{}
  71. require.Empty(t, svc.GenerateSessionHash(&ParsedRequest{}))
  72. }
  73. func TestGenerateSessionHash_MetadataHasHighestPriority(t *testing.T) {
  74. svc := &GatewayService{}
  75. metadata := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000"
  76. parsed := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello")}, metadata), nil)
  77. hash := svc.GenerateSessionHash(parsed)
  78. require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", hash, "metadata session_id should have highest priority")
  79. }
  80. func TestGenerateSessionHash_SystemPlusMessages(t *testing.T) {
  81. svc := &GatewayService{}
  82. withSystem := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello")}, ""), nil)
  83. withoutSystem := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "hello")}, ""), nil)
  84. h1 := svc.GenerateSessionHash(withSystem)
  85. h2 := svc.GenerateSessionHash(withoutSystem)
  86. require.NotEmpty(t, h1)
  87. require.NotEmpty(t, h2)
  88. require.NotEqual(t, h1, h2, "system prompt should be part of digest, producing different hash")
  89. }
  90. func TestGenerateSessionHash_SystemOnlyProducesHash(t *testing.T) {
  91. svc := &GatewayService{}
  92. parsed := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", nil, ""), nil)
  93. hash := svc.GenerateSessionHash(parsed)
  94. require.NotEmpty(t, hash, "system prompt alone should produce a hash as part of full digest")
  95. }
  96. func TestGenerateSessionHash_DifferentSystemsSameMessages(t *testing.T) {
  97. svc := &GatewayService{}
  98. parsed1 := mustParseSessionHashRequest(t, anthropicSessionBody("You are assistant A.", []any{msg("user", "hello")}, ""), nil)
  99. parsed2 := mustParseSessionHashRequest(t, anthropicSessionBody("You are assistant B.", []any{msg("user", "hello")}, ""), nil)
  100. h1 := svc.GenerateSessionHash(parsed1)
  101. h2 := svc.GenerateSessionHash(parsed2)
  102. require.NotEqual(t, h1, h2, "different system prompts with same messages should produce different hashes")
  103. }
  104. func TestGenerateSessionHash_SameSystemSameMessages(t *testing.T) {
  105. svc := &GatewayService{}
  106. mk := func() *ParsedRequest {
  107. return mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello"), msg("assistant", "hi")}, ""), nil)
  108. }
  109. h1 := svc.GenerateSessionHash(mk())
  110. h2 := svc.GenerateSessionHash(mk())
  111. require.Equal(t, h1, h2, "same system + same messages should produce identical hash")
  112. }
  113. func TestGenerateSessionHash_DifferentMessagesProduceDifferentHash(t *testing.T) {
  114. svc := &GatewayService{}
  115. parsed1 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "help me with Go")}, ""), nil)
  116. parsed2 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "help me with Python")}, ""), nil)
  117. h1 := svc.GenerateSessionHash(parsed1)
  118. h2 := svc.GenerateSessionHash(parsed2)
  119. require.NotEqual(t, h1, h2, "same system but different messages should produce different hashes")
  120. }
  121. func TestGenerateSessionHash_DifferentSessionContextProducesDifferentHash(t *testing.T) {
  122. svc := &GatewayService{}
  123. body := anthropicSessionBody(nil, []any{msg("user", "hello")}, "")
  124. parsed1 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "192.168.1.1", UserAgent: "Mozilla/5.0", APIKeyID: 100})
  125. parsed2 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "10.0.0.1", UserAgent: "curl/7.0", APIKeyID: 200})
  126. h1 := svc.GenerateSessionHash(parsed1)
  127. h2 := svc.GenerateSessionHash(parsed2)
  128. require.NotEmpty(t, h1)
  129. require.NotEmpty(t, h2)
  130. require.NotEqual(t, h1, h2, "same messages but different SessionContext should produce different hashes")
  131. }
  132. func TestGenerateSessionHash_SameSessionContextProducesSameHash(t *testing.T) {
  133. svc := &GatewayService{}
  134. ctx := &SessionContext{ClientIP: "192.168.1.1", UserAgent: "Mozilla/5.0", APIKeyID: 100}
  135. body := anthropicSessionBody(nil, []any{msg("user", "hello")}, "")
  136. mk := func() *ParsedRequest { return mustParseSessionHashRequest(t, body, ctx) }
  137. h1 := svc.GenerateSessionHash(mk())
  138. h2 := svc.GenerateSessionHash(mk())
  139. require.Equal(t, h1, h2, "same messages + same SessionContext should produce identical hash")
  140. }
  141. func TestGenerateSessionHash_MetadataOverridesSessionContext(t *testing.T) {
  142. svc := &GatewayService{}
  143. metadata := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000"
  144. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "hello")}, metadata), &SessionContext{ClientIP: "192.168.1.1", UserAgent: "Mozilla/5.0", APIKeyID: 100})
  145. hash := svc.GenerateSessionHash(parsed)
  146. require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", hash, "metadata session_id should take priority over SessionContext")
  147. }
  148. func TestGenerateSessionHash_MetadataJSON_HasHighestPriority(t *testing.T) {
  149. svc := &GatewayService{}
  150. metadata := `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`
  151. parsed := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello")}, metadata), nil)
  152. hash := svc.GenerateSessionHash(parsed)
  153. require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", hash, "JSON format metadata session_id should have highest priority")
  154. }
  155. func TestGenerateSessionHash_NilSessionContextBackwardCompatible(t *testing.T) {
  156. svc := &GatewayService{}
  157. body := anthropicSessionBody(nil, []any{msg("user", "hello")}, "")
  158. withCtx := mustParseSessionHashRequest(t, body, nil)
  159. withoutCtx := mustParseSessionHashRequest(t, body, nil)
  160. h1 := svc.GenerateSessionHash(withCtx)
  161. h2 := svc.GenerateSessionHash(withoutCtx)
  162. require.Equal(t, h1, h2, "nil SessionContext should produce same hash as no SessionContext")
  163. }
  164. func TestGenerateSessionHash_ContinuousConversation_HashChangesWithMessages(t *testing.T) {
  165. svc := &GatewayService{}
  166. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  167. round1 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello")}, ""), ctx)
  168. round2 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello"), msg("assistant", "Hi there!"), msg("user", "How are you?")}, ""), ctx)
  169. round3 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello"), msg("assistant", "Hi there!"), msg("user", "How are you?"), msg("assistant", "I'm doing well!"), msg("user", "Tell me a joke")}, ""), ctx)
  170. h1 := svc.GenerateSessionHash(round1)
  171. h2 := svc.GenerateSessionHash(round2)
  172. h3 := svc.GenerateSessionHash(round3)
  173. require.NotEmpty(t, h1)
  174. require.NotEmpty(t, h2)
  175. require.NotEmpty(t, h3)
  176. require.NotEqual(t, h1, h2, "different conversation rounds should produce different hashes")
  177. require.NotEqual(t, h2, h3, "each new round should produce a different hash")
  178. require.NotEqual(t, h1, h3, "round 1 and round 3 should differ")
  179. }
  180. func TestGenerateSessionHash_ContinuousConversation_SameRoundSameHash(t *testing.T) {
  181. svc := &GatewayService{}
  182. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  183. body := anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello"), msg("assistant", "Hi there!"), msg("user", "How are you?")}, "")
  184. mk := func() *ParsedRequest { return mustParseSessionHashRequest(t, body, ctx) }
  185. h1 := svc.GenerateSessionHash(mk())
  186. h2 := svc.GenerateSessionHash(mk())
  187. require.Equal(t, h1, h2, "same conversation state should produce identical hash on retry")
  188. }
  189. func TestGenerateSessionHash_ResponsesDifferentInputProducesDifferentHash(t *testing.T) {
  190. svc := &GatewayService{}
  191. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "codex_cli_rs/0.1.0", APIKeyID: 1}
  192. first := mustParseResponsesSessionHashRequest(t, `{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"help me with Go"}]}]}`, ctx)
  193. second := mustParseResponsesSessionHashRequest(t, `{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"help me with Python"}]}]}`, ctx)
  194. h1 := svc.GenerateSessionHash(first)
  195. h2 := svc.GenerateSessionHash(second)
  196. require.NotEmpty(t, h1)
  197. require.NotEmpty(t, h2)
  198. require.NotEqual(t, h1, h2, "different Responses input should produce different hashes for the same client")
  199. }
  200. func TestGenerateSessionHash_ResponsesGrowingInputKeepsStableHash(t *testing.T) {
  201. svc := &GatewayService{}
  202. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "codex_cli_rs/0.1.0", APIKeyID: 1}
  203. round1 := mustParseResponsesSessionHashRequest(t, `{"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"Be concise."}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"help me with Go"}]}]}`, ctx)
  204. round2 := mustParseResponsesSessionHashRequest(t, `{"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"Be concise."}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"help me with Go"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Sure."}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"add tests"}]}]}`, ctx)
  205. h1 := svc.GenerateSessionHash(round1)
  206. h2 := svc.GenerateSessionHash(round2)
  207. require.NotEmpty(t, h1)
  208. require.Equal(t, h1, h2, "Responses input growth should preserve the hash when the conversation prefix is stable")
  209. }
  210. func TestGenerateSessionHash_MessagesPathIgnoresResponsesInput(t *testing.T) {
  211. svc := &GatewayService{}
  212. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  213. first := mustParseResponsesSessionHashRequest(t, `{"messages":[{"role":"user","content":"hello"}],"input":"first"}`, ctx)
  214. second := mustParseResponsesSessionHashRequest(t, `{"messages":[{"role":"user","content":"hello"}],"input":"second"}`, ctx)
  215. h1 := svc.GenerateSessionHash(first)
  216. h2 := svc.GenerateSessionHash(second)
  217. require.Equal(t, h1, h2, "existing messages fallback should remain authoritative when messages contain text")
  218. }
  219. func TestGenerateSessionHash_ResponsesInputDoesNotOverrideHigherPrioritySources(t *testing.T) {
  220. svc := &GatewayService{}
  221. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  222. t.Run("metadata user id", func(t *testing.T) {
  223. metadata := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000"
  224. parsed := mustParseResponsesSessionHashRequest(t, `{"metadata":{"user_id":"`+metadata+`"},"input":"hello"}`, ctx)
  225. require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", svc.GenerateSessionHash(parsed))
  226. })
  227. t.Run("cache control", func(t *testing.T) {
  228. body := `{"system":[{"type":"text","text":"stable cache anchor","cache_control":{"type":"ephemeral"}}],"input":"hello"}`
  229. first := mustParseResponsesSessionHashRequest(t, body, ctx)
  230. second := mustParseResponsesSessionHashRequest(t, body, &SessionContext{ClientIP: "9.8.7.6", UserAgent: "other", APIKeyID: 2})
  231. require.Equal(t, svc.GenerateSessionHash(first), svc.GenerateSessionHash(second))
  232. })
  233. }
  234. func TestGenerateSessionHash_MessageRollback(t *testing.T) {
  235. svc := &GatewayService{}
  236. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  237. original := mustParseSessionHashRequest(t, anthropicSessionBody("System prompt", []any{msg("user", "msg1"), msg("assistant", "reply1"), msg("user", "msg2"), msg("assistant", "reply2"), msg("user", "msg3")}, ""), ctx)
  238. rollback := mustParseSessionHashRequest(t, anthropicSessionBody("System prompt", []any{msg("user", "msg1"), msg("assistant", "reply1"), msg("user", "msg2"), msg("assistant", "reply2"), msg("user", "different msg3")}, ""), ctx)
  239. hOrig := svc.GenerateSessionHash(original)
  240. hRollback := svc.GenerateSessionHash(rollback)
  241. require.NotEqual(t, hOrig, hRollback, "rollback with different last message should produce different hash")
  242. }
  243. func TestGenerateSessionHash_MessageRollbackSameContent(t *testing.T) {
  244. svc := &GatewayService{}
  245. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  246. body := anthropicSessionBody("System prompt", []any{msg("user", "msg1"), msg("assistant", "reply1"), msg("user", "msg2")}, "")
  247. mk := func() *ParsedRequest { return mustParseSessionHashRequest(t, body, ctx) }
  248. h1 := svc.GenerateSessionHash(mk())
  249. h2 := svc.GenerateSessionHash(mk())
  250. require.Equal(t, h1, h2, "rollback and resend same content should produce same hash")
  251. }
  252. func TestGenerateSessionHash_SameSystemDifferentUsers(t *testing.T) {
  253. svc := &GatewayService{}
  254. user1 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a code reviewer.", []any{msg("user", "Review this Go code")}, ""), &SessionContext{ClientIP: "1.1.1.1", UserAgent: "vscode", APIKeyID: 1})
  255. user2 := mustParseSessionHashRequest(t, anthropicSessionBody("You are a code reviewer.", []any{msg("user", "Review this Python code")}, ""), &SessionContext{ClientIP: "2.2.2.2", UserAgent: "vscode", APIKeyID: 2})
  256. h1 := svc.GenerateSessionHash(user1)
  257. h2 := svc.GenerateSessionHash(user2)
  258. require.NotEqual(t, h1, h2, "different users with different messages should get different hashes")
  259. }
  260. func TestGenerateSessionHash_SameSystemSameMessageDifferentContext(t *testing.T) {
  261. svc := &GatewayService{}
  262. body := anthropicSessionBody("You are a helpful assistant.", []any{msg("user", "hello")}, "")
  263. user1 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: "Mozilla/5.0", APIKeyID: 10})
  264. user2 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "2.2.2.2", UserAgent: "Mozilla/5.0", APIKeyID: 20})
  265. h1 := svc.GenerateSessionHash(user1)
  266. h2 := svc.GenerateSessionHash(user2)
  267. require.NotEqual(t, h1, h2, "CRITICAL: same system+messages but different users should get different hashes")
  268. }
  269. func TestGenerateSessionHash_SessionContext_IPDifference(t *testing.T) {
  270. svc := &GatewayService{}
  271. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  272. base := func(ip string) *ParsedRequest {
  273. return mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: ip, UserAgent: "same-ua", APIKeyID: 1})
  274. }
  275. h1 := svc.GenerateSessionHash(base("1.1.1.1"))
  276. h2 := svc.GenerateSessionHash(base("2.2.2.2"))
  277. require.NotEqual(t, h1, h2, "different IP should produce different hash")
  278. }
  279. func TestGenerateSessionHash_SessionContext_UADifference(t *testing.T) {
  280. svc := &GatewayService{}
  281. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  282. base := func(ua string) *ParsedRequest {
  283. return mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: ua, APIKeyID: 1})
  284. }
  285. h1 := svc.GenerateSessionHash(base("Mozilla/5.0"))
  286. h2 := svc.GenerateSessionHash(base("curl/7.0"))
  287. require.NotEqual(t, h1, h2, "different User-Agent should produce different hash")
  288. }
  289. func TestGenerateSessionHash_SessionContext_UAVersionNoiseIgnored(t *testing.T) {
  290. svc := &GatewayService{}
  291. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  292. base := func(ua string) *ParsedRequest {
  293. return mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: ua, APIKeyID: 1})
  294. }
  295. h1 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.0"))
  296. h2 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.1"))
  297. require.Equal(t, h1, h2, "version-only User-Agent changes should not perturb the sticky session hash")
  298. }
  299. func TestGenerateSessionHash_SessionContext_FreeformUAVersionNoiseIgnored(t *testing.T) {
  300. svc := &GatewayService{}
  301. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  302. base := func(ua string) *ParsedRequest {
  303. return mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: ua, APIKeyID: 1})
  304. }
  305. h1 := svc.GenerateSessionHash(base("Codex CLI 0.1.0"))
  306. h2 := svc.GenerateSessionHash(base("Codex CLI 0.1.1"))
  307. require.Equal(t, h1, h2, "free-form version-only User-Agent changes should not perturb the sticky session hash")
  308. }
  309. func TestGenerateSessionHash_SessionContext_APIKeyIDDifference(t *testing.T) {
  310. svc := &GatewayService{}
  311. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  312. base := func(keyID int64) *ParsedRequest {
  313. return mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: "same-ua", APIKeyID: keyID})
  314. }
  315. h1 := svc.GenerateSessionHash(base(1))
  316. h2 := svc.GenerateSessionHash(base(2))
  317. require.NotEqual(t, h1, h2, "different APIKeyID should produce different hash")
  318. }
  319. func TestGenerateSessionHash_MultipleUsersSameFirstMessage(t *testing.T) {
  320. svc := &GatewayService{}
  321. hashes := make(map[string]bool)
  322. body := anthropicSessionBody(nil, []any{msg("user", "hello")}, "")
  323. for i := 0; i < 5; i++ {
  324. parsed := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "192.168.1." + string(rune('1'+i)), UserAgent: "client-" + string(rune('A'+i)), APIKeyID: int64(i + 1)})
  325. h := svc.GenerateSessionHash(parsed)
  326. require.NotEmpty(t, h)
  327. require.False(t, hashes[h], "hash collision detected for user %d", i)
  328. hashes[h] = true
  329. }
  330. require.Len(t, hashes, 5, "5 different users should produce 5 unique hashes")
  331. }
  332. func TestGenerateSessionHash_SameUserGrowingConversation(t *testing.T) {
  333. svc := &GatewayService{}
  334. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "browser", APIKeyID: 42}
  335. messages := []any{
  336. msg("user", "msg1"), msg("assistant", "reply1"), msg("user", "msg2"), msg("assistant", "reply2"),
  337. msg("user", "msg3"), msg("assistant", "reply3"), msg("user", "msg4"),
  338. }
  339. prevHash := ""
  340. for round := 1; round <= len(messages); round += 2 {
  341. parsed := mustParseSessionHashRequest(t, anthropicSessionBody("System", messages[:round], ""), ctx)
  342. h := svc.GenerateSessionHash(parsed)
  343. require.NotEmpty(t, h, "round %d hash should not be empty", round)
  344. if prevHash != "" {
  345. require.NotEqual(t, prevHash, h, "round %d hash should differ from previous round", round)
  346. }
  347. prevHash = h
  348. h2 := svc.GenerateSessionHash(parsed)
  349. require.Equal(t, h, h2, "retry of round %d should produce same hash", round)
  350. }
  351. }
  352. func TestGenerateSessionHash_MultipleUserMessages(t *testing.T) {
  353. svc := &GatewayService{}
  354. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  355. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "first"), msg("user", "second"), msg("user", "third"), msg("user", "fourth"), msg("user", "fifth")}, ""), ctx)
  356. h := svc.GenerateSessionHash(parsed)
  357. require.NotEmpty(t, h)
  358. parsed2 := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "first"), msg("user", "CHANGED"), msg("user", "third"), msg("user", "fourth"), msg("user", "fifth")}, ""), ctx)
  359. h2 := svc.GenerateSessionHash(parsed2)
  360. require.NotEqual(t, h, h2, "changing any message should change the hash")
  361. }
  362. func TestGenerateSessionHash_MessageOrderMatters(t *testing.T) {
  363. svc := &GatewayService{}
  364. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  365. parsed1 := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "alpha"), msg("user", "beta")}, ""), ctx)
  366. parsed2 := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", "beta"), msg("user", "alpha")}, ""), ctx)
  367. h1 := svc.GenerateSessionHash(parsed1)
  368. h2 := svc.GenerateSessionHash(parsed2)
  369. require.NotEqual(t, h1, h2, "message order should affect the hash")
  370. }
  371. func TestGenerateSessionHash_StructuredContent(t *testing.T) {
  372. svc := &GatewayService{}
  373. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  374. content := []any{map[string]any{"type": "text", "text": "Look at this"}, map[string]any{"type": "text", "text": "And this too"}}
  375. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{msg("user", content)}, ""), ctx)
  376. h := svc.GenerateSessionHash(parsed)
  377. require.NotEmpty(t, h, "structured content should produce a hash")
  378. }
  379. func TestGenerateSessionHash_ArraySystemPrompt(t *testing.T) {
  380. svc := &GatewayService{}
  381. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  382. system := []any{map[string]any{"type": "text", "text": "You are a helpful assistant."}, map[string]any{"type": "text", "text": "Be concise."}}
  383. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(system, []any{msg("user", "hello")}, ""), ctx)
  384. h := svc.GenerateSessionHash(parsed)
  385. require.NotEmpty(t, h, "array system prompt should produce a hash")
  386. }
  387. func TestGenerateSessionHash_CacheControlOverridesSessionContext(t *testing.T) {
  388. svc := &GatewayService{}
  389. system := []any{map[string]any{"type": "text", "text": "You are a tool-specific assistant.", "cache_control": map[string]any{"type": "ephemeral"}}}
  390. body := anthropicSessionBody(system, []any{msg("user", "hello")}, "")
  391. parsed1 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: "ua1", APIKeyID: 100})
  392. parsed2 := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "2.2.2.2", UserAgent: "ua2", APIKeyID: 200})
  393. h1 := svc.GenerateSessionHash(parsed1)
  394. h2 := svc.GenerateSessionHash(parsed2)
  395. require.Equal(t, h1, h2, "cache_control ephemeral has higher priority, SessionContext should not affect result")
  396. }
  397. func TestGenerateSessionHash_EmptyMessages(t *testing.T) {
  398. svc := &GatewayService{}
  399. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{}, ""), &SessionContext{ClientIP: "1.1.1.1", UserAgent: "test", APIKeyID: 1})
  400. h := svc.GenerateSessionHash(parsed)
  401. require.NotEmpty(t, h, "empty messages with SessionContext should still produce a hash from context")
  402. }
  403. func TestGenerateSessionHash_EmptyMessagesNoContext(t *testing.T) {
  404. svc := &GatewayService{}
  405. parsed := mustParseSessionHashRequest(t, anthropicSessionBody(nil, []any{}, ""), nil)
  406. h := svc.GenerateSessionHash(parsed)
  407. require.Empty(t, h, "empty messages without SessionContext should produce empty hash")
  408. }
  409. func TestGenerateSessionHash_SessionContextWithEmptyFields(t *testing.T) {
  410. svc := &GatewayService{}
  411. body := anthropicSessionBody(nil, []any{msg("user", "test")}, "")
  412. withEmptyCtx := mustParseSessionHashRequest(t, body, &SessionContext{ClientIP: "", UserAgent: "", APIKeyID: 0})
  413. withoutCtx := mustParseSessionHashRequest(t, body, nil)
  414. h1 := svc.GenerateSessionHash(withEmptyCtx)
  415. h2 := svc.GenerateSessionHash(withoutCtx)
  416. require.NotEqual(t, h1, h2, "empty-field SessionContext should still differ from nil SessionContext")
  417. }
  418. func TestGenerateSessionHash_LongConversation(t *testing.T) {
  419. svc := &GatewayService{}
  420. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "test", APIKeyID: 1}
  421. messages := make([]any, 0, 40)
  422. for i := 0; i < 20; i++ {
  423. messages = append(messages, msg("user", "user message "+string(rune('A'+i))))
  424. messages = append(messages, msg("assistant", "assistant reply "+string(rune('A'+i))))
  425. }
  426. parsed := mustParseSessionHashRequest(t, anthropicSessionBody("System prompt", messages, ""), ctx)
  427. h := svc.GenerateSessionHash(parsed)
  428. require.NotEmpty(t, h)
  429. moreMessages := append(append([]any{}, messages...), msg("user", "one more"), msg("assistant", "ok"))
  430. parsed2 := mustParseSessionHashRequest(t, anthropicSessionBody("System prompt", moreMessages, ""), ctx)
  431. h2 := svc.GenerateSessionHash(parsed2)
  432. require.NotEqual(t, h, h2, "adding more messages to long conversation should change hash")
  433. }
  434. func TestGenerateSessionHash_GeminiContentsProducesHash(t *testing.T) {
  435. svc := &GatewayService{}
  436. parsed := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "Hello from Gemini")}), &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1})
  437. h := svc.GenerateSessionHash(parsed)
  438. require.NotEmpty(t, h, "Gemini contents with parts should produce a non-empty hash")
  439. }
  440. func TestGenerateSessionHash_GeminiDifferentContentsDifferentHash(t *testing.T) {
  441. svc := &GatewayService{}
  442. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  443. parsed1 := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "Hello")}), ctx)
  444. parsed2 := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "Goodbye")}), ctx)
  445. h1 := svc.GenerateSessionHash(parsed1)
  446. h2 := svc.GenerateSessionHash(parsed2)
  447. require.NotEqual(t, h1, h2, "different Gemini contents should produce different hashes")
  448. }
  449. func TestGenerateSessionHash_GeminiSameContentsSameHash(t *testing.T) {
  450. svc := &GatewayService{}
  451. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  452. body := geminiSessionBody(nil, []any{geminiMsg("user", "Hello"), geminiMsg("model", "Hi there!")})
  453. mk := func() *ParsedRequest { return mustParseGeminiSessionHashRequest(t, body, ctx) }
  454. h1 := svc.GenerateSessionHash(mk())
  455. h2 := svc.GenerateSessionHash(mk())
  456. require.Equal(t, h1, h2, "same Gemini contents should produce identical hash")
  457. }
  458. func TestGenerateSessionHash_GeminiMultiTurnHashChanges(t *testing.T) {
  459. svc := &GatewayService{}
  460. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  461. round1 := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "hello")}), ctx)
  462. round2 := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "hello"), geminiMsg("model", "Hi!"), geminiMsg("user", "How are you?")}), ctx)
  463. h1 := svc.GenerateSessionHash(round1)
  464. h2 := svc.GenerateSessionHash(round2)
  465. require.NotEmpty(t, h1)
  466. require.NotEmpty(t, h2)
  467. require.NotEqual(t, h1, h2, "Gemini multi-turn should produce different hashes per round")
  468. }
  469. func TestGenerateSessionHash_GeminiDifferentUsersSameContentDifferentHash(t *testing.T) {
  470. svc := &GatewayService{}
  471. body := geminiSessionBody(nil, []any{geminiMsg("user", "hello")})
  472. user1 := mustParseGeminiSessionHashRequest(t, body, &SessionContext{ClientIP: "1.1.1.1", UserAgent: "gemini-cli", APIKeyID: 10})
  473. user2 := mustParseGeminiSessionHashRequest(t, body, &SessionContext{ClientIP: "2.2.2.2", UserAgent: "gemini-cli", APIKeyID: 20})
  474. h1 := svc.GenerateSessionHash(user1)
  475. h2 := svc.GenerateSessionHash(user2)
  476. require.NotEqual(t, h1, h2, "CRITICAL: different Gemini users with same content must get different hashes")
  477. }
  478. func TestGenerateSessionHash_GeminiSystemInstructionAffectsHash(t *testing.T) {
  479. svc := &GatewayService{}
  480. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  481. withSys := mustParseGeminiSessionHashRequest(t, geminiSessionBody([]any{map[string]any{"text": "You are a coding assistant."}}, []any{geminiMsg("user", "hello")}), ctx)
  482. withoutSys := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "hello")}), ctx)
  483. h1 := svc.GenerateSessionHash(withSys)
  484. h2 := svc.GenerateSessionHash(withoutSys)
  485. require.NotEqual(t, h1, h2, "systemInstruction should affect the hash")
  486. }
  487. func TestGenerateSessionHash_GeminiMultiPartMessage(t *testing.T) {
  488. svc := &GatewayService{}
  489. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  490. parsed := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "Part 1", "Part 2", "Part 3")}), ctx)
  491. h := svc.GenerateSessionHash(parsed)
  492. require.NotEmpty(t, h, "multi-part Gemini message should produce a hash")
  493. parsed2 := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, []any{geminiMsg("user", "Part 1", "CHANGED", "Part 3")}), ctx)
  494. h2 := svc.GenerateSessionHash(parsed2)
  495. require.NotEqual(t, h, h2, "changing a part should change the hash")
  496. }
  497. func TestGenerateSessionHash_GeminiNonTextPartsIgnored(t *testing.T) {
  498. svc := &GatewayService{}
  499. ctx := &SessionContext{ClientIP: "1.2.3.4", UserAgent: "gemini-cli", APIKeyID: 1}
  500. content := []any{map[string]any{"role": "user", "parts": []any{map[string]any{"text": "Describe this image"}, map[string]any{"inline_data": map[string]any{"mime_type": "image/png", "data": "base64..."}}}}}
  501. parsed := mustParseGeminiSessionHashRequest(t, geminiSessionBody(nil, content), ctx)
  502. h := svc.GenerateSessionHash(parsed)
  503. require.NotEmpty(t, h, "Gemini message with mixed parts should still produce a hash from text parts")
  504. }
  505. func TestGenerateSessionHash_GeminiMultiTurnHashNotSticky(t *testing.T) {
  506. svc := &GatewayService{}
  507. ctx := &SessionContext{ClientIP: "10.0.0.1", UserAgent: "gemini-cli", APIKeyID: 42}
  508. rounds := []string{
  509. geminiSessionBody([]any{map[string]any{"text": "You are a coding assistant."}}, []any{geminiMsg("user", "Write a Go function")}),
  510. geminiSessionBody([]any{map[string]any{"text": "You are a coding assistant."}}, []any{geminiMsg("user", "Write a Go function"), geminiMsg("model", "func hello() {}"), geminiMsg("user", "Add error handling")}),
  511. geminiSessionBody([]any{map[string]any{"text": "You are a coding assistant."}}, []any{geminiMsg("user", "Write a Go function"), geminiMsg("model", "func hello() {}"), geminiMsg("user", "Add error handling"), geminiMsg("model", "func hello() error { return nil }"), geminiMsg("user", "Now add tests")}),
  512. }
  513. hashes := make([]string, len(rounds))
  514. for i, body := range rounds {
  515. parsed := mustParseGeminiSessionHashRequest(t, body, ctx)
  516. hashes[i] = svc.GenerateSessionHash(parsed)
  517. require.NotEmpty(t, hashes[i], "round %d hash should not be empty", i+1)
  518. }
  519. require.NotEqual(t, hashes[0], hashes[1], "round 1 vs 2 hash should differ (contents grow)")
  520. require.NotEqual(t, hashes[1], hashes[2], "round 2 vs 3 hash should differ (contents grow)")
  521. require.NotEqual(t, hashes[0], hashes[2], "round 1 vs 3 hash should differ")
  522. parsedAgain := mustParseGeminiSessionHashRequest(t, rounds[1], ctx)
  523. h2Again := svc.GenerateSessionHash(parsedAgain)
  524. require.Equal(t, hashes[1], h2Again, "retry of same round should produce same hash")
  525. }
  526. func TestGenerateSessionHash_GeminiEndToEnd(t *testing.T) {
  527. svc := &GatewayService{}
  528. body := geminiSessionBody([]any{map[string]any{"text": "You are a coding assistant."}}, []any{geminiMsg("user", "Write a Go function"), geminiMsg("model", "Here is a function..."), geminiMsg("user", "Now add error handling")})
  529. parsed := mustParseGeminiSessionHashRequest(t, body, &SessionContext{ClientIP: "10.0.0.1", UserAgent: "gemini-cli/1.0", APIKeyID: 42})
  530. h := svc.GenerateSessionHash(parsed)
  531. require.NotEmpty(t, h, "end-to-end Gemini flow should produce a hash")
  532. parsed2 := mustParseGeminiSessionHashRequest(t, body, &SessionContext{ClientIP: "10.0.0.1", UserAgent: "gemini-cli/1.0", APIKeyID: 42})
  533. h2 := svc.GenerateSessionHash(parsed2)
  534. require.Equal(t, h, h2, "same request should produce same hash")
  535. parsed3 := mustParseGeminiSessionHashRequest(t, body, &SessionContext{ClientIP: "10.0.0.2", UserAgent: "gemini-cli/1.0", APIKeyID: 99})
  536. h3 := svc.GenerateSessionHash(parsed3)
  537. require.NotEqual(t, h, h3, "different user with same Gemini request should get different hash")
  538. }