gateway_body_order_test.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  9. "github.com/stretchr/testify/require"
  10. "github.com/tidwall/gjson"
  11. )
  12. type gatewayTTLSettingRepo struct {
  13. data map[string]string
  14. }
  15. func (r *gatewayTTLSettingRepo) Get(context.Context, string) (*Setting, error) {
  16. return nil, ErrSettingNotFound
  17. }
  18. func (r *gatewayTTLSettingRepo) GetValue(_ context.Context, key string) (string, error) {
  19. if r == nil {
  20. return "", ErrSettingNotFound
  21. }
  22. v, ok := r.data[key]
  23. if !ok {
  24. return "", ErrSettingNotFound
  25. }
  26. return v, nil
  27. }
  28. func (r *gatewayTTLSettingRepo) Set(_ context.Context, key, value string) error {
  29. if r == nil {
  30. return errors.New("setting repo is nil")
  31. }
  32. if r.data == nil {
  33. r.data = map[string]string{}
  34. }
  35. r.data[key] = value
  36. return nil
  37. }
  38. func (r *gatewayTTLSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  39. result := make(map[string]string)
  40. if r == nil {
  41. return result, nil
  42. }
  43. for _, key := range keys {
  44. if v, ok := r.data[key]; ok {
  45. result[key] = v
  46. }
  47. }
  48. return result, nil
  49. }
  50. func (r *gatewayTTLSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
  51. if r == nil {
  52. return errors.New("setting repo is nil")
  53. }
  54. if r.data == nil {
  55. r.data = map[string]string{}
  56. }
  57. for key, value := range settings {
  58. r.data[key] = value
  59. }
  60. return nil
  61. }
  62. func (r *gatewayTTLSettingRepo) GetAll(context.Context) (map[string]string, error) {
  63. result := make(map[string]string)
  64. if r == nil {
  65. return result, nil
  66. }
  67. for key, value := range r.data {
  68. result[key] = value
  69. }
  70. return result, nil
  71. }
  72. func (r *gatewayTTLSettingRepo) Delete(_ context.Context, key string) error {
  73. if r != nil {
  74. delete(r.data, key)
  75. }
  76. return nil
  77. }
  78. func assertJSONTokenOrder(t *testing.T, body string, tokens ...string) {
  79. t.Helper()
  80. last := -1
  81. for _, token := range tokens {
  82. pos := strings.Index(body, token)
  83. require.NotEqualf(t, -1, pos, "missing token %s in body %s", token, body)
  84. require.Greaterf(t, pos, last, "token %s should appear after previous tokens in body %s", token, body)
  85. last = pos
  86. }
  87. }
  88. func TestReplaceModelInBody_PreservesTopLevelFieldOrder(t *testing.T) {
  89. svc := &GatewayService{}
  90. body := []byte(`{"alpha":1,"model":"claude-3-5-sonnet-latest","messages":[],"omega":2}`)
  91. result := svc.replaceModelInBody(body, "claude-3-5-sonnet-20241022")
  92. resultStr := string(result)
  93. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"messages"`, `"omega"`)
  94. require.Contains(t, resultStr, `"model":"claude-3-5-sonnet-20241022"`)
  95. }
  96. func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.T) {
  97. body := []byte(`{"alpha":1,"model":"claude-3-5-sonnet-latest","temperature":0.2,"system":"You are OpenCode, the best coding agent on the planet.","messages":[],"tool_choice":{"type":"auto"},"omega":2}`)
  98. result, modelID := normalizeClaudeOAuthRequestBody(body, "claude-3-5-sonnet-latest", claudeOAuthNormalizeOptions{
  99. injectMetadata: true,
  100. metadataUserID: "user-1",
  101. })
  102. resultStr := string(result)
  103. require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID)
  104. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`, `"max_tokens"`)
  105. require.Contains(t, resultStr, `"temperature":0.2`)
  106. require.NotContains(t, resultStr, `"tool_choice"`)
  107. require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`)
  108. require.Contains(t, resultStr, `"tools":[]`)
  109. require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`)
  110. require.Contains(t, resultStr, `"max_tokens":128000`)
  111. }
  112. func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) {
  113. body := []byte(`{"alpha":1,"system":[{"id":"block-1","type":"text","text":"Custom"}],"messages":[],"omega":2}`)
  114. result := injectClaudeCodePrompt(body, []any{
  115. map[string]any{"id": "block-1", "type": "text", "text": "Custom"},
  116. })
  117. resultStr := string(result)
  118. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
  119. require.Contains(t, resultStr, `{"id":"block-1","type":"text","text":"`+claudeCodeSystemPrompt+`\n\nCustom"}`)
  120. }
  121. func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) {
  122. body := []byte(`{"alpha":1,"system":[{"type":"text","text":"s1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"s2","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"m1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m2","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m3","cache_control":{"type":"ephemeral"}}]}],"omega":2}`)
  123. result := enforceCacheControlLimit(body)
  124. resultStr := string(result)
  125. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
  126. require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`))
  127. }
  128. func TestEnforceCacheControlLimit_CountsToolsAndPreservesMessageAnchorsFirst(t *testing.T) {
  129. body := []byte(`{"alpha":1,"system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"m1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m2","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m3","cache_control":{"type":"ephemeral"}}]}],"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral"}}],"omega":2}`)
  130. result := enforceCacheControlLimit(body)
  131. resultStr := string(result)
  132. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"tools"`, `"omega"`)
  133. require.Equal(t, 4, strings.Count(resultStr, `"cache_control"`))
  134. require.True(t, gjson.GetBytes(result, "system.0.cache_control").Exists())
  135. require.True(t, gjson.GetBytes(result, "messages.0.content.0.cache_control").Exists())
  136. require.True(t, gjson.GetBytes(result, "messages.0.content.1.cache_control").Exists())
  137. require.True(t, gjson.GetBytes(result, "messages.0.content.2.cache_control").Exists())
  138. require.False(t, gjson.GetBytes(result, "tools.0.cache_control").Exists())
  139. }
  140. func TestInjectAnthropicCacheControlTTL1h_OnlyUpdatesExistingEphemeralCacheControl(t *testing.T) {
  141. body := []byte(`{"alpha":1,"cache_control":{"type":"ephemeral"},"system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}},{"type":"text","text":"plain"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}},{"type":"text","text":"non","cache_control":{"type":"persistent","ttl":"5m"}}]}],"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral"}}],"omega":2}`)
  142. result := injectAnthropicCacheControlTTL1h(body)
  143. resultStr := string(result)
  144. assertJSONTokenOrder(t, resultStr, `"alpha"`, `"cache_control"`, `"system"`, `"messages"`, `"tools"`, `"omega"`)
  145. require.Equal(t, "1h", gjson.GetBytes(result, "cache_control.ttl").String())
  146. require.Equal(t, "1h", gjson.GetBytes(result, "system.0.cache_control.ttl").String())
  147. require.False(t, gjson.GetBytes(result, "system.1.cache_control").Exists())
  148. require.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String())
  149. require.Equal(t, "5m", gjson.GetBytes(result, "messages.0.content.1.cache_control.ttl").String())
  150. require.Equal(t, "1h", gjson.GetBytes(result, "tools.0.cache_control.ttl").String())
  151. }
  152. func TestGatewayCacheTTLGlobalSetting_TargetResolution(t *testing.T) {
  153. repo := &gatewayTTLSettingRepo{data: map[string]string{
  154. SettingKeyEnableAnthropicCacheTTL1hInjection: "true",
  155. }}
  156. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
  157. svc := &GatewayService{
  158. settingService: NewSettingService(repo, &config.Config{}),
  159. }
  160. account := &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}
  161. target, ok := svc.resolveCacheTTLUsageOverrideTarget(context.Background(), account)
  162. require.True(t, ok)
  163. require.Equal(t, cacheTTLTarget5m, target)
  164. account.Extra = map[string]any{
  165. "cache_ttl_override_enabled": true,
  166. "cache_ttl_override_target": "1h",
  167. }
  168. target, ok = svc.resolveCacheTTLUsageOverrideTarget(context.Background(), account)
  169. require.True(t, ok)
  170. require.Equal(t, cacheTTLTarget1h, target)
  171. }
  172. func TestGatewayCacheTTLGlobalSetting_RequestInjectionScope(t *testing.T) {
  173. repo := &gatewayTTLSettingRepo{data: map[string]string{
  174. SettingKeyEnableAnthropicCacheTTL1hInjection: "true",
  175. }}
  176. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
  177. svc := &GatewayService{
  178. settingService: NewSettingService(repo, &config.Config{}),
  179. }
  180. require.True(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
  181. require.True(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
  182. require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}))
  183. require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
  184. repo.data[SettingKeyEnableAnthropicCacheTTL1hInjection] = "false"
  185. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
  186. require.False(t, svc.shouldInjectAnthropicCacheTTL1h(context.Background(), &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
  187. }