Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user