openai_gateway_reasoning_failover_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. package handler
  2. import (
  3. "testing"
  4. "github.com/Wei-Shaw/sub2api/internal/service"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. // kiroReasoningCanonicalBody mirrors a native OpenAI Responses request whose input
  9. // carries a provider-specific encrypted reasoning item (Kiro shape: encrypted_content
  10. // coupled with an id and summary), plus surrounding message items.
  11. const kiroReasoningCanonicalBody = `{"model":"gpt-5.1","stream":false,"input":[` +
  12. `{"type":"message","role":"user","content":"hello"},` +
  13. `{"type":"reasoning","id":"rs_kiro_abc123","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]},` +
  14. `{"type":"message","role":"assistant","content":"hi"}` +
  15. `]}`
  16. func newOpenAIPassthroughAccount(id int64, passthrough bool) *service.Account {
  17. extra := map[string]any{"openai_passthrough": passthrough}
  18. return &service.Account{
  19. ID: id,
  20. Platform: service.PlatformOpenAI,
  21. Type: service.AccountTypeAPIKey,
  22. Extra: extra,
  23. }
  24. }
  25. func reasoningItemCount(t *testing.T, body []byte) int {
  26. t.Helper()
  27. count := 0
  28. gjson.GetBytes(body, "input").ForEach(func(_, item gjson.Result) bool {
  29. if item.Get("type").String() == "reasoning" {
  30. count++
  31. }
  32. return true
  33. })
  34. return count
  35. }
  36. // TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning drives the real
  37. // account-switch/request-body seam used inside OpenAIGatewayHandler.Responses: the
  38. // same deriveOpenAIForwardAttemptBody call the failover loop makes, fed real
  39. // *service.Account values in sequence. It proves the first passthrough attempt gets
  40. // the untouched reasoning item, and after a failover to a non-passthrough account
  41. // the second attempt body has the entire reasoning item removed — while the
  42. // immutable canonical body is never mutated.
  43. func TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning(t *testing.T) {
  44. h := &OpenAIGatewayHandler{}
  45. canonical := []byte(kiroReasoningCanonicalBody)
  46. kiro := newOpenAIPassthroughAccount(1, true) // Kiro: openai_passthrough=true
  47. bedrock := newOpenAIPassthroughAccount(2, false) // Bedrock Mantle: passthrough disabled
  48. state := &openAIPassthroughFailoverState{}
  49. // Attempt 1 — passthrough (Kiro): original reasoning item must survive intact.
  50. firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
  51. require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt must keep the reasoning item")
  52. require.Equal(t, "ENC_BLOB", gjson.GetBytes(firstBody, "input.1.encrypted_content").String())
  53. require.Equal(t, "rs_kiro_abc123", gjson.GetBytes(firstBody, "input.1.id").String())
  54. require.JSONEq(t, kiroReasoningCanonicalBody, string(firstBody), "first attempt body must equal the canonical body")
  55. // Attempt 2 — failover switches to non-passthrough (Bedrock): the whole
  56. // provider-specific encrypted reasoning item (id/summary/encrypted_content) is dropped.
  57. secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
  58. require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt must drop the reasoning item entirely")
  59. require.False(t, gjson.GetBytes(secondBody, "input.#(encrypted_content)").Exists(), "no encrypted_content may remain")
  60. require.NotContains(t, string(secondBody), "rs_kiro_abc123", "coupled reasoning id must be gone")
  61. require.NotContains(t, string(secondBody), "ENC_BLOB")
  62. // Surviving message items are preserved.
  63. require.Equal(t, 2, int(gjson.GetBytes(secondBody, "input.#").Int()))
  64. require.Equal(t, "hello", gjson.GetBytes(secondBody, "input.0.content").String())
  65. require.Equal(t, "hi", gjson.GetBytes(secondBody, "input.1.content").String())
  66. // The canonical body is immutable across attempts.
  67. require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
  68. }
  69. // TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning proves that
  70. // non-passthrough attempts before any passthrough attempt, passthrough-family
  71. // failovers, and switching into passthrough forward the canonical reasoning item
  72. // unchanged.
  73. func TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning(t *testing.T) {
  74. h := &OpenAIGatewayHandler{}
  75. canonical := []byte(kiroReasoningCanonicalBody)
  76. t.Run("non_passthrough_to_non_passthrough", func(t *testing.T) {
  77. state := &openAIPassthroughFailoverState{}
  78. first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(10, false), state)
  79. second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(11, false), state)
  80. require.Equal(t, 1, reasoningItemCount(t, first))
  81. require.Equal(t, 1, reasoningItemCount(t, second), "Bedrock-family failover must preserve reasoning")
  82. require.JSONEq(t, kiroReasoningCanonicalBody, string(second))
  83. })
  84. t.Run("passthrough_to_passthrough", func(t *testing.T) {
  85. state := &openAIPassthroughFailoverState{}
  86. first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(20, true), state)
  87. second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(21, true), state)
  88. require.Equal(t, 1, reasoningItemCount(t, first))
  89. require.Equal(t, 1, reasoningItemCount(t, second), "Kiro-family failover must preserve reasoning")
  90. })
  91. t.Run("non_passthrough_to_passthrough", func(t *testing.T) {
  92. state := &openAIPassthroughFailoverState{}
  93. _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(30, false), state)
  94. second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(31, true), state)
  95. require.Equal(t, 1, reasoningItemCount(t, second), "switching into passthrough must preserve reasoning")
  96. })
  97. t.Run("same_account_pool_retry", func(t *testing.T) {
  98. state := &openAIPassthroughFailoverState{}
  99. kiro := newOpenAIPassthroughAccount(40, true)
  100. _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
  101. retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
  102. require.Equal(t, 1, reasoningItemCount(t, retry), "same-account retry must preserve reasoning")
  103. })
  104. }
  105. // TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries proves
  106. // that once a passthrough account has been attempted, all later non-passthrough
  107. // retries and failovers remain sanitized instead of restoring the canonical Kiro
  108. // reasoning item after the first Bedrock failure.
  109. func TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries(t *testing.T) {
  110. h := &OpenAIGatewayHandler{}
  111. canonical := []byte(kiroReasoningCanonicalBody)
  112. state := &openAIPassthroughFailoverState{}
  113. kiro := newOpenAIPassthroughAccount(50, true)
  114. bedrockA := newOpenAIPassthroughAccount(51, false)
  115. bedrockB := newOpenAIPassthroughAccount(52, false)
  116. first := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
  117. second := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
  118. retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
  119. nextAccount := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockB, state)
  120. require.Equal(t, 1, reasoningItemCount(t, first))
  121. require.Equal(t, 0, reasoningItemCount(t, second))
  122. require.Equal(t, 0, reasoningItemCount(t, retry), "same Bedrock account retry must remain sanitized")
  123. require.Equal(t, 0, reasoningItemCount(t, nextAccount), "later Bedrock account must remain sanitized")
  124. require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
  125. }