| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- package handler
- import (
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- // kiroReasoningCanonicalBody mirrors a native OpenAI Responses request whose input
- // carries a provider-specific encrypted reasoning item (Kiro shape: encrypted_content
- // coupled with an id and summary), plus surrounding message items.
- const kiroReasoningCanonicalBody = `{"model":"gpt-5.1","stream":false,"input":[` +
- `{"type":"message","role":"user","content":"hello"},` +
- `{"type":"reasoning","id":"rs_kiro_abc123","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]},` +
- `{"type":"message","role":"assistant","content":"hi"}` +
- `]}`
- func newOpenAIPassthroughAccount(id int64, passthrough bool) *service.Account {
- extra := map[string]any{"openai_passthrough": passthrough}
- return &service.Account{
- ID: id,
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Extra: extra,
- }
- }
- func reasoningItemCount(t *testing.T, body []byte) int {
- t.Helper()
- count := 0
- gjson.GetBytes(body, "input").ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() == "reasoning" {
- count++
- }
- return true
- })
- return count
- }
- // TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning drives the real
- // account-switch/request-body seam used inside OpenAIGatewayHandler.Responses: the
- // same deriveOpenAIForwardAttemptBody call the failover loop makes, fed real
- // *service.Account values in sequence. It proves the first passthrough attempt gets
- // the untouched reasoning item, and after a failover to a non-passthrough account
- // the second attempt body has the entire reasoning item removed — while the
- // immutable canonical body is never mutated.
- func TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning(t *testing.T) {
- h := &OpenAIGatewayHandler{}
- canonical := []byte(kiroReasoningCanonicalBody)
- kiro := newOpenAIPassthroughAccount(1, true) // Kiro: openai_passthrough=true
- bedrock := newOpenAIPassthroughAccount(2, false) // Bedrock Mantle: passthrough disabled
- state := &openAIPassthroughFailoverState{}
- // Attempt 1 — passthrough (Kiro): original reasoning item must survive intact.
- firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
- require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt must keep the reasoning item")
- require.Equal(t, "ENC_BLOB", gjson.GetBytes(firstBody, "input.1.encrypted_content").String())
- require.Equal(t, "rs_kiro_abc123", gjson.GetBytes(firstBody, "input.1.id").String())
- require.JSONEq(t, kiroReasoningCanonicalBody, string(firstBody), "first attempt body must equal the canonical body")
- // Attempt 2 — failover switches to non-passthrough (Bedrock): the whole
- // provider-specific encrypted reasoning item (id/summary/encrypted_content) is dropped.
- secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
- require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt must drop the reasoning item entirely")
- require.False(t, gjson.GetBytes(secondBody, "input.#(encrypted_content)").Exists(), "no encrypted_content may remain")
- require.NotContains(t, string(secondBody), "rs_kiro_abc123", "coupled reasoning id must be gone")
- require.NotContains(t, string(secondBody), "ENC_BLOB")
- // Surviving message items are preserved.
- require.Equal(t, 2, int(gjson.GetBytes(secondBody, "input.#").Int()))
- require.Equal(t, "hello", gjson.GetBytes(secondBody, "input.0.content").String())
- require.Equal(t, "hi", gjson.GetBytes(secondBody, "input.1.content").String())
- // The canonical body is immutable across attempts.
- require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
- }
- // TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning proves that
- // non-passthrough attempts before any passthrough attempt, passthrough-family
- // failovers, and switching into passthrough forward the canonical reasoning item
- // unchanged.
- func TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning(t *testing.T) {
- h := &OpenAIGatewayHandler{}
- canonical := []byte(kiroReasoningCanonicalBody)
- t.Run("non_passthrough_to_non_passthrough", func(t *testing.T) {
- state := &openAIPassthroughFailoverState{}
- first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(10, false), state)
- second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(11, false), state)
- require.Equal(t, 1, reasoningItemCount(t, first))
- require.Equal(t, 1, reasoningItemCount(t, second), "Bedrock-family failover must preserve reasoning")
- require.JSONEq(t, kiroReasoningCanonicalBody, string(second))
- })
- t.Run("passthrough_to_passthrough", func(t *testing.T) {
- state := &openAIPassthroughFailoverState{}
- first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(20, true), state)
- second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(21, true), state)
- require.Equal(t, 1, reasoningItemCount(t, first))
- require.Equal(t, 1, reasoningItemCount(t, second), "Kiro-family failover must preserve reasoning")
- })
- t.Run("non_passthrough_to_passthrough", func(t *testing.T) {
- state := &openAIPassthroughFailoverState{}
- _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(30, false), state)
- second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(31, true), state)
- require.Equal(t, 1, reasoningItemCount(t, second), "switching into passthrough must preserve reasoning")
- })
- t.Run("same_account_pool_retry", func(t *testing.T) {
- state := &openAIPassthroughFailoverState{}
- kiro := newOpenAIPassthroughAccount(40, true)
- _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
- retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
- require.Equal(t, 1, reasoningItemCount(t, retry), "same-account retry must preserve reasoning")
- })
- }
- // TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries proves
- // that once a passthrough account has been attempted, all later non-passthrough
- // retries and failovers remain sanitized instead of restoring the canonical Kiro
- // reasoning item after the first Bedrock failure.
- func TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries(t *testing.T) {
- h := &OpenAIGatewayHandler{}
- canonical := []byte(kiroReasoningCanonicalBody)
- state := &openAIPassthroughFailoverState{}
- kiro := newOpenAIPassthroughAccount(50, true)
- bedrockA := newOpenAIPassthroughAccount(51, false)
- bedrockB := newOpenAIPassthroughAccount(52, false)
- first := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
- second := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
- retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
- nextAccount := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockB, state)
- require.Equal(t, 1, reasoningItemCount(t, first))
- require.Equal(t, 0, reasoningItemCount(t, second))
- require.Equal(t, 0, reasoningItemCount(t, retry), "same Bedrock account retry must remain sanitized")
- require.Equal(t, 0, reasoningItemCount(t, nextAccount), "later Bedrock account must remain sanitized")
- require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
- }
|