补齐 OpenAI 与 Grok 协议兼容处理

This commit is contained in:
IanShaw
2026-08-19 23:13:29 -07:00
parent cf3577a3c5
commit acce29af27
127 changed files with 9472 additions and 1034 deletions
@@ -212,7 +212,7 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo
if adapter.ToolSearch {
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
normalizeClientToolOutput(typed)
normalizeToolSearchOutput(typed)
changed = true
}
}
@@ -256,6 +256,20 @@ func normalizeClientToolOutput(item map[string]any) {
item["output"] = string(encoded)
}
func normalizeToolSearchOutput(item map[string]any) {
if _, exists := item["output"]; !exists {
if tools, hasTools := item["tools"]; hasTools {
item["output"] = tools
} else {
return
}
}
normalizeClientToolOutput(item)
delete(item, "tools")
delete(item, "status")
delete(item, "execution")
}
func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool {
choice, ok := req["tool_choice"].(map[string]any)
if !ok {
@@ -66,6 +66,68 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(
require.Equal(t, "team__send", namespaceCall["name"])
}
func TestAdaptResponsesClientTools_LowersToolSearchDiscoveryPayloadWithoutOutput(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{"type": "tool_search"}},
"input": []any{map[string]any{
"type": "tool_search_output",
"call_id": "search_1",
"tools": []any{map[string]any{"name": "github", "type": "namespace"}},
"status": "completed",
"execution": "client",
}},
}
mapping, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
require.True(t, mapping.ToolSearch)
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
require.Equal(t, "function_call_output", item["type"])
require.Equal(t, "search_1", item["call_id"])
require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, requireResponsesClientToolValue[string](t, item["output"]))
require.NotContains(t, item, "tools")
require.NotContains(t, item, "status")
require.NotContains(t, item, "execution")
}
func TestAdaptResponsesClientTools_ToolSearchExplicitOutputTakesPriority(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{"type": "tool_search"}},
"input": []any{map[string]any{
"type": "tool_search_output", "call_id": "search_1",
"output": "authoritative", "tools": []any{map[string]any{"name": "ignored"}},
}},
}
_, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
require.Equal(t, "authoritative", item["output"])
require.NotContains(t, item, "tools")
}
func TestAdaptResponsesClientTools_DoesNotInventMissingToolSearchOutput(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{"type": "tool_search"}},
"input": []any{map[string]any{
"type": "tool_search_output", "call_id": "search_1",
"status": "incomplete", "execution": "client",
}},
}
_, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
require.Equal(t, "function_call_output", item["type"])
require.NotContains(t, item, "output")
require.Equal(t, "incomplete", item["status"])
require.Equal(t, "client", item["execution"])
}
func requireResponsesClientToolValue[T any](t *testing.T, value any) T {
t.Helper()
typed, ok := value.(T)
+141 -8
View File
@@ -15,6 +15,7 @@ import (
)
const stickySessionPrefix = "sticky_session:"
const openAIResponsesSessionWindowPrefix = "openai_responses_session_window:"
const liveCallPrefix = "live:call:"
type gatewayCache struct {
@@ -31,6 +32,10 @@ func buildSessionKey(groupID int64, sessionHash string) string {
return fmt.Sprintf("%s%d:%s", stickySessionPrefix, groupID, sessionHash)
}
func buildOpenAIResponsesSessionWindowKey(groupID int64, sessionHash string) string {
return fmt.Sprintf("%s%d:%s", openAIResponsesSessionWindowPrefix, groupID, sessionHash)
}
func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
key := buildSessionKey(groupID, sessionHash)
accountID, err := c.rdb.Get(ctx, key).Int64()
@@ -65,6 +70,97 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64
return c.rdb.Del(ctx, key).Err()
}
var claimOpenAIResponsesSessionWindowScript = redis.NewScript(`
local previous = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return previous
`)
var compareAndRefreshOpenAIResponsesSessionWindowScript = redis.NewScript(`
local current = redis.call('GET', KEYS[1])
if current == false or current ~= ARGV[1] then
return 0
end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
`)
var compareAndDeleteOpenAIResponsesSessionWindowScript = redis.NewScript(`
local current = redis.call('GET', KEYS[1])
if current == false or current ~= ARGV[1] then
return 0
end
redis.call('DEL', KEYS[1])
return 1
`)
func (c *gatewayCache) ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) {
if c == nil || c.rdb == nil {
return nil, errors.New("gateway cache unavailable")
}
if len(owner) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
return nil, errors.New("invalid OpenAI Responses session-window claim")
}
result, err := claimOpenAIResponsesSessionWindowScript.Run(
ctx,
c.rdb,
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
owner,
ttl.Milliseconds(),
).Result()
if errors.Is(err, redis.Nil) {
return nil, nil
}
if err != nil {
return nil, err
}
switch value := result.(type) {
case nil:
return nil, nil
case string:
return []byte(value), nil
case []byte:
return append([]byte(nil), value...), nil
default:
return nil, fmt.Errorf("unexpected OpenAI Responses session-window claim result %T", result)
}
}
func (c *gatewayCache) CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) {
if c == nil || c.rdb == nil {
return false, errors.New("gateway cache unavailable")
}
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
return false, errors.New("invalid OpenAI Responses session-window refresh")
}
n, err := compareAndRefreshOpenAIResponsesSessionWindowScript.Run(
ctx,
c.rdb,
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
expected,
ttl.Milliseconds(),
).Int()
return n == 1, err
}
func (c *gatewayCache) CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) {
if c == nil || c.rdb == nil {
return false, errors.New("gateway cache unavailable")
}
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" {
return false, errors.New("invalid OpenAI Responses session-window delete")
}
n, err := compareAndDeleteOpenAIResponsesSessionWindowScript.Run(
ctx,
c.rdb,
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
expected,
).Int()
return n == 1, err
}
var _ service.OpenAIWSSessionPreemptionCache = (*gatewayCache)(nil)
const (
grokVideoPendingBillingPrefix = "grok_video_pending:"
grokVideoBilledPrefix = "grok_video_billed:"
@@ -173,23 +269,60 @@ func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (
return val, nil
}
const cyberSessionBlockPrefix = "cyber_session_block:"
const (
cyberSessionBlockPrefix = "cyber_session_block:"
cyberSessionScopePrefix = "cyber_session_scope:"
)
// SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。
// 存储值 "1" 作为存在标记(IsCyberSessionBlocked 只检查 key 是否存在,不读值)。
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
return c.rdb.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl).Err()
// SetCyberSessionBlocked atomically writes all exact blocks and their optional
// coarse source scope with the same TTL.
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
if len(keys) == 0 {
return nil
}
pipe := c.rdb.TxPipeline()
for _, key := range keys {
if key != "" {
pipe.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl)
}
}
if scopeKey != "" {
pipe.Set(ctx, cyberSessionScopePrefix+scopeKey, "1", ttl)
}
_, err := pipe.Exec(ctx)
return err
}
// IsCyberSessionBlocked 查询会话是否在屏蔽表中。
func (c *gatewayCache) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) {
n, err := c.rdb.Exists(ctx, cyberSessionBlockPrefix+key).Result()
func (c *gatewayCache) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
n, err := c.rdb.Exists(ctx, cyberSessionScopePrefix+scopeKey).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// FindCyberSessionBlocked checks transcript-prefix candidates in one Redis
// round trip and returns the first blocked key in caller order.
func (c *gatewayCache) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
if len(keys) == 0 {
return "", nil
}
redisKeys := make([]string, len(keys))
for i, key := range keys {
redisKeys[i] = cyberSessionBlockPrefix + key
}
values, err := c.rdb.MGet(ctx, redisKeys...).Result()
if err != nil {
return "", err
}
for i, value := range values {
if value != nil {
return keys[i], nil
}
}
return "", nil
}
var claimLiveControllerScript = redis.NewScript(`
local key = KEYS[1]
local target = ARGV[1]
@@ -0,0 +1,31 @@
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestGatewayCacheCyberBlockWritesScopeAndExactKeysTogether(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
store, ok := NewGatewayCache(client).(service.CyberSessionBlockStore)
require.True(t, ok)
ctx := context.Background()
require.NoError(t, store.SetCyberSessionBlocked(ctx, "scope-1", []string{"block-1", "block-2"}, time.Minute))
active, err := store.IsCyberSessionScopeActive(ctx, "scope-1")
require.NoError(t, err)
require.True(t, active)
matched, err := store.FindCyberSessionBlocked(ctx, []string{"missing", "block-1", "block-2"})
require.NoError(t, err)
require.Equal(t, "block-1", matched)
require.Greater(t, server.TTL(cyberSessionScopePrefix+"scope-1"), time.Duration(0))
require.Equal(t, server.TTL(cyberSessionBlockPrefix+"block-1"), server.TTL(cyberSessionBlockPrefix+"block-2"))
}
@@ -12,6 +12,30 @@ import (
const tempUnschedPrefix = "temp_unsched:account:"
const openAIAPIKeyHealthFailurePrefix = "openai_apikey_health:"
var openAIAPIKeyHealthFailureScript = redis.NewScript(`
local key = KEYS[1]
local sequence_key = key .. ':sequence'
local now = redis.call('TIME')
local now_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000)
local window_ms = tonumber(ARGV[1]) * 60 * 1000
local threshold = tonumber(ARGV[2])
local sequence = redis.call('INCR', sequence_key)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
redis.call('ZADD', key, now_ms, tostring(now_ms) .. ':' .. tostring(sequence))
local count = redis.call('ZCARD', key)
local ttl = math.max(60, (tonumber(ARGV[1]) + 1) * 60)
redis.call('EXPIRE', key, ttl)
redis.call('EXPIRE', sequence_key, ttl)
if count >= threshold then
redis.call('DEL', key, sequence_key)
return {count, 1}
end
return {count, 0}
`)
var tempUnschedSetScript = redis.NewScript(`
local key = KEYS[1]
local new_until = tonumber(ARGV[1])
@@ -89,3 +113,36 @@ func (c *tempUnschedCache) DeleteTempUnsched(ctx context.Context, accountID int6
key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
return c.rdb.Del(ctx, key).Err()
}
func (c *tempUnschedCache) openAIAPIKeyHealthKey(accountID int64) string {
// The hash tag keeps the rolling window and sequence key in one Redis
// Cluster slot even though the Lua script derives the latter dynamically.
return fmt.Sprintf("%s{%d}:failures", openAIAPIKeyHealthFailurePrefix, accountID)
}
func (c *tempUnschedCache) RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (int64, bool, error) {
if windowMinutes < 1 {
windowMinutes = 1
}
if threshold < 1 {
threshold = 1
}
result, err := openAIAPIKeyHealthFailureScript.Run(ctx, c.rdb, []string{c.openAIAPIKeyHealthKey(accountID)}, windowMinutes, threshold).Slice()
if err != nil {
return 0, false, fmt.Errorf("record OpenAI API key health failure: %w", err)
}
if len(result) != 2 {
return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result length %d", len(result))
}
count, countOK := result[0].(int64)
tripped, trippedOK := result[1].(int64)
if !countOK || !trippedOK {
return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result types %T/%T", result[0], result[1])
}
return count, tripped == 1, nil
}
func (c *tempUnschedCache) ResetOpenAIAPIKeyHealthFailures(ctx context.Context, accountID int64) error {
key := c.openAIAPIKeyHealthKey(accountID)
return c.rdb.Del(ctx, key, key+":sequence").Err()
}
@@ -0,0 +1,35 @@
package repository
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestOpenAIAPIKeyHealthCacheTripsAndSuccessResetsWindow(t *testing.T) {
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
store, ok := NewTempUnschedCache(client).(service.OpenAIAPIKeyHealthCache)
require.True(t, ok)
ctx := context.Background()
for attempt := 1; attempt <= 2; attempt++ {
count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3)
require.NoError(t, err)
require.EqualValues(t, attempt, count)
require.False(t, tripped)
}
require.NoError(t, store.ResetOpenAIAPIKeyHealthFailures(ctx, 42))
for attempt := 1; attempt <= 3; attempt++ {
count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3)
require.NoError(t, err)
require.EqualValues(t, attempt, count)
require.Equal(t, attempt == 3, tripped)
}
}
+18 -5
View File
@@ -1279,6 +1279,19 @@ func (a *Account) IsOpenAIOAuth() bool {
return a.IsOpenAI() && a.Type == AccountTypeOAuth
}
// IsOpenAIOAuthLike reports OpenAI credentials that use the ChatGPT/Codex
// inference protocol. Setup tokens share that forwarding contract but do not
// participate in the refreshable OAuth credential lifecycle.
func (a *Account) IsOpenAIOAuthLike() bool {
return a != nil && a.IsOpenAI() && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
}
// UsesOpenAICodexProtocol preserves legacy OpenAI gateway OAuth routing for
// accounts whose platform is implicit, while adding OpenAI SetupToken.
func (a *Account) UsesOpenAICodexProtocol() bool {
return a != nil && (a.Type == AccountTypeOAuth || a.IsOpenAIOAuthLike())
}
func (a *Account) IsOpenAIChatGPTSubscription() bool {
if !a.IsOpenAIOAuth() {
return false
@@ -1595,14 +1608,14 @@ func (a *Account) GetOpenAIUserAgent() string {
}
func (a *Account) GetChatGPTAccountID() string {
if !a.IsOpenAIOAuth() {
if !a.IsOpenAIOAuthLike() {
return ""
}
return a.GetCredential("chatgpt_account_id")
}
func (a *Account) IsChatGPTAccountFedRAMP() bool {
if !a.IsOpenAIOAuth() || a.Credentials == nil {
if !a.IsOpenAIOAuthLike() || a.Credentials == nil {
return false
}
v, ok := a.Credentials["chatgpt_account_is_fedramp"]
@@ -1814,7 +1827,7 @@ func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapabilit
}
switch capability {
case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
return a.Type == AccountTypeOAuth || a.Type == AccountTypeAPIKey
return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey
default:
return true
}
@@ -1917,7 +1930,7 @@ func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
if a.IsOpenAIOAuth() {
if a.IsOpenAIOAuthLike() {
if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
return enabled
}
@@ -2020,7 +2033,7 @@ func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) stri
return OpenAIWSIngressModeOff, true
}
if a.IsOpenAIOAuth() {
if a.IsOpenAIOAuthLike() {
if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
return mode
}
@@ -532,6 +532,8 @@ const (
// SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling.
SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings"
// SettingKeyOpenAIAPIKeyHealthBreakerSettings stores the opt-in OpenAI pool API-key breaker config.
SettingKeyOpenAIAPIKeyHealthBreakerSettings = "openai_apikey_health_breaker_settings"
// =========================
// Stream Timeout Handling
@@ -66,6 +66,35 @@ func TestCheckErrorPolicy(t *testing.T) {
body: []byte(`"error"`),
expected: ErrorPolicySkipped,
},
{
name: "global_529_bypasses_custom_error_code_filter",
account: &Account{
ID: 33,
Type: AccountTypeAPIKey,
Platform: PlatformOpenAI,
Credentials: map[string]any{
"custom_error_codes_enabled": true,
"custom_error_codes": []any{float64(429)},
},
},
statusCode: 529,
body: []byte(`{"error":{"message":"overloaded"}}`),
expected: ErrorPolicyMatched,
},
{
name: "global_529_bypasses_pool_mode",
account: &Account{
ID: 34,
Type: AccountTypeAPIKey,
Platform: PlatformOpenAI,
Credentials: map[string]any{
"pool_mode": true,
},
},
statusCode: 529,
body: []byte(`{"error":{"message":"overloaded"}}`),
expected: ErrorPolicyMatched,
},
{
name: "temp_unschedulable_hit_returns_temp_unscheduled",
account: &Account{
@@ -37,6 +37,14 @@ func (s *GatewayService) ForwardAsResponses(
) (*ForwardResult, error) {
startTime := time.Now()
normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body)
if err != nil {
return nil, err
}
if normalized {
body = normalizedBody
}
// 1. Lower Codex client-side tools to function tools understood by Anthropic.
adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body)
if err != nil {
@@ -18,6 +18,7 @@ import (
type gatewayForwardErrorPolicyRepoStub struct {
AccountRepository
tempCalls int
overloadCalls int
modelRateLimitCalls []gatewayForwardModelRateLimitCall
}
@@ -39,6 +40,11 @@ func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context,
return nil
}
func (r *gatewayForwardErrorPolicyRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
r.overloadCalls++
return nil
}
// 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
// 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
// 返回,供 handler 照常提交 usage 记录。
@@ -250,9 +256,8 @@ func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *te
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, 529, failoverErr.StatusCode)
require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
require.Len(t, repo.modelRateLimitCalls, 1, "synthetic 529 must participate in temp-unschedulable rules")
require.Equal(t, account.ID, repo.modelRateLimitCalls[0].accountID)
require.Equal(t, parsed.Model, repo.modelRateLimitCalls[0].scope)
require.Equal(t, 1, repo.overloadCalls, "synthetic 529 must apply global overload cooldown")
require.Empty(t, repo.modelRateLimitCalls, "global 529 cooldown must take precedence over custom model rules")
require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover")
}
+6 -4
View File
@@ -192,8 +192,8 @@ func openAIStreamEventIsTerminalWithType(data, eventType string) bool {
}
func openAIStreamEventTypeIsTerminal(eventType string) bool {
switch eventType {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
switch strings.TrimSpace(eventType) {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
return true
default:
return false
@@ -672,8 +672,10 @@ type UpstreamFailoverError struct {
ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
SameAccountRetryDelay time.Duration
SameAccountRetryDeadline time.Time
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
Stage GatewayFailureStage
Scope GatewayFailureScope
Reason GatewayFailureReason
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
)
// isGrokContentPolicyRejection identifies request-scoped safety refusals from
@@ -197,6 +199,12 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r
if isGrokContentPolicyRejection(statusCode, responseBody) {
return false
}
// A 422 emitted by xAI's ModelInput decoder is account/runtime compatibility,
// not quota exhaustion. Another account may run a different upstream build,
// so fail over without applying an account cooldown.
if isGrokDecoderCompatibilityError(statusCode, responseBody) {
return true
}
decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
switch decision.Class {
case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity:
@@ -205,6 +213,59 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r
return s.shouldFailoverUpstreamError(statusCode)
}
func isGrokDecoderCompatibilityError(statusCode int, responseBody []byte) bool {
if statusCode != http.StatusUnprocessableEntity || len(responseBody) == 0 {
return false
}
for _, candidate := range grokStructuredErrorMessageCandidates(responseBody) {
message := strings.ToLower(candidate)
decoderSignal := strings.Contains(message, "untagged enum") ||
strings.Contains(message, "decode") ||
strings.Contains(message, "deserialize") ||
strings.Contains(message, "deserializ") ||
strings.Contains(message, "decoder")
inputSignal := strings.Contains(message, "modelinput") ||
strings.Contains(message, "model input") ||
strings.Contains(message, "input[") ||
strings.Contains(message, "input.")
messageContentSignal := (strings.Contains(message, "messages[") ||
strings.Contains(message, "messages.")) &&
strings.Contains(message, "content") &&
strings.Contains(message, "did not match any variant")
if decoderSignal && (inputSignal || messageContentSignal) {
return true
}
}
return false
}
func grokStructuredErrorMessageCandidates(body []byte) []string {
candidates := make([]string, 0, 6)
appendCandidate := func(result gjson.Result) {
if !result.Exists() {
return
}
value := strings.TrimSpace(result.String())
if value != "" {
candidates = append(candidates, value)
}
}
appendCandidate(gjson.GetBytes(body, "error.message"))
appendCandidate(gjson.GetBytes(body, "error.error"))
errorNode := gjson.GetBytes(body, "error")
if errorNode.Type == gjson.String {
appendCandidate(errorNode)
}
appendCandidate(gjson.GetBytes(body, "message"))
appendCandidate(gjson.GetBytes(body, "detail"))
if !json.Valid(body) {
if plaintext := strings.TrimSpace(string(body)); plaintext != "" {
candidates = append(candidates, plaintext)
}
}
return candidates
}
// applyGrokForbiddenPolicy applies an administrator's existing temporary
// unschedulable rules to a non-content 403. It reports true only when a rule
// matched; unmatched responses retain the legacy entitlement cooldown.
@@ -0,0 +1,300 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type openAIStream403AccountRepo struct {
AccountRepository
setErrorCalls int
}
func (r *openAIStream403AccountRepo) SetError(context.Context, int64, string) error {
r.setErrorCalls++
return nil
}
func TestOpenAIUpstreamAccessStateClassification(t *testing.T) {
tests := []struct {
name string
body string
}{
{"workspace_code", `{"detail":{"code":"deactivated_workspace"}}`},
{"disabled_account", `{"error":{"message":"Your account is disabled"}}`},
{"suspended_workspace", `{"response":{"error":{"message":"This workspace has been suspended"}}}`},
{"deactivated_organization", `{"detail":{"message":"The organization is deactivated"}}`},
{"scalar_detail", `{"detail":"This workspace has been disabled"}`},
{"suspended_org_code", `{"error":{"code":"org_suspended"}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(tt.body)
require.True(t, isOpenAIUpstreamAccessStateError("", body))
require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(http.StatusForbidden, "", body))
require.True(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusForbidden, body))
err := newOpenAIUpstreamFailoverError(http.StatusForbidden, nil, body, "", true)
require.True(t, err.IsCredentialFailure())
require.Equal(t, GatewayFailureScopeAccount, err.Scope)
require.Equal(t, OpenAIUpstreamAccessStateReason, err.Reason)
require.Equal(t, NextAccountRetry, err.NextAccountAction)
require.False(t, err.RetryableOnSameAccount)
require.False(t, err.RequestScopedTransient)
require.Equal(t, http.StatusBadGateway, err.ClientStatusCode)
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, err.ClientMessage)
})
}
}
func TestOpenAIUpstreamAccessStateDoesNotScanEchoedJSON(t *testing.T) {
body := []byte(`{"error":{"code":"invalid_request_error","message":"Invalid input"},"echo":{"prompt":"my account is disabled"}}`)
require.False(t, isOpenAIUpstreamAccessStateError("", body))
require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body))
}
func TestOpenAICyberPolicyWrapped5xxNeverFailsOver(t *testing.T) {
body := []byte(`{"error":{"code":"cyber_policy","message":"blocked"}}`)
svc := &OpenAIGatewayService{}
require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "wrapped upstream failure", body))
require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadGateway, body))
}
func TestOpenAICapacityFailoverCarriesSafeTerminalResponse(t *testing.T) {
message := "Our servers are currently overloaded. Please try again later."
body := []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`)
err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, message, false)
require.True(t, err.IsOpenAICapacityShed())
require.Equal(t, http.StatusServiceUnavailable, err.ClientStatusCode)
require.Equal(t, message, err.ClientMessage)
require.NotContains(t, err.ClientMessage, "server_is_overloaded")
}
func TestOpenAIStreamSemanticStatusesPreservedAcrossTerminalShapes(t *testing.T) {
tests := []struct {
name string
body string
status int
wantFailover bool
}{
{"unauthorized", `{"type":"error","error":{"type":"authentication_error","code":"invalid_api_key","message":"unauthorized"}}`, http.StatusUnauthorized, true},
{"forbidden", `{"type":"response.failed","response":{"error":{"type":"permission_error","message":"forbidden"}}}`, http.StatusForbidden, false},
{"rate_limit", `{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`, http.StatusTooManyRequests, true},
{"overload_529", `{"type":"error","error":{"status_code":529,"code":"overloaded","message":"overloaded"}}`, 529, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := []byte(tt.body)
message := extractOpenAISSEErrorMessage(payload)
require.Equal(t, tt.status, openAIStreamFailureStatus(payload, message))
require.Equal(t, tt.wantFailover, openAIStreamErrorEventShouldFailover(payload, message))
})
}
}
func TestOpenAIStreamBareErrorUsesSemanticFailover(t *testing.T) {
payload := []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)
require.True(t, openAIStreamErrorEventShouldFailover(payload, "slow down"))
}
func TestOpenAIStream403FailoverRequiresStructuredAccountCredentialSignal(t *testing.T) {
tests := []struct {
name string
payload string
want bool
}{
{
name: "ordinary permission error",
payload: `{"type":"response.failed","response":{"error":{"type":"permission_error","code":"forbidden","message":"access denied for this request"}}}`,
},
{
name: "explicit 403 request status",
payload: `{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"forbidden content"}}`,
},
{
name: "structured access state",
payload: `{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`,
want: true,
},
{
name: "explicit credential auth code",
payload: `{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`,
want: true,
},
{
name: "explicit authentication type",
payload: `{"type":"error","error":{"type":"authentication_error","status_code":403,"message":"credential rejected"}}`,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := []byte(tt.payload)
message := extractOpenAISSEErrorMessage(payload)
require.Equal(t, http.StatusForbidden, openAIStreamFailureStatus(payload, message))
require.Equal(t, tt.want, openAIStreamFailedEventShouldFailover(payload, message))
require.Equal(t, tt.want, openAIStreamErrorEventShouldFailover(payload, message))
})
}
}
func TestOpenAIStream403PostOutputAccountSideEffectsIgnoreRequestPermissionErrors(t *testing.T) {
repo := &openAIStream403AccountRepo{}
rateLimits := &RateLimitService{accountRepo: repo}
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
account := &Account{ID: 918, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"access denied for this request"}}`)
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "access denied for this request", nil)
require.Equal(t, http.StatusForbidden, status)
require.False(t, disabled)
require.Zero(t, repo.setErrorCalls)
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestOpenAIStream403ExplicitCredentialAuthAppliesAccountSideEffects(t *testing.T) {
repo := &openAIStream403AccountRepo{}
rateLimits := &RateLimitService{accountRepo: repo}
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
account := &Account{ID: 917, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`)
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "credential rejected", nil)
require.Equal(t, http.StatusForbidden, status)
require.True(t, disabled)
require.Equal(t, 1, repo.setErrorCalls)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestOpenAIWSStandaloneFailedStructured403AppliesAccountSideEffectsOnce(t *testing.T) {
repo := &openAIStream403AccountRepo{}
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
account := &Account{ID: 923, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
failed := []byte(`{"type":"response.failed","response":{"error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}}`)
require.True(t, svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failed))
require.Equal(t, 1, repo.setErrorCalls)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestOpenAIWSPairedStructured403SideEffectsCanBeDeduplicated(t *testing.T) {
repo := &openAIStream403AccountRepo{}
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
account := &Account{ID: 924, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
errorEvent := []byte(`{"type":"error","error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}`)
failedEvent := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}}`)
applied := svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, errorEvent)
if !applied {
applied = svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failedEvent)
}
require.True(t, applied)
require.Equal(t, 1, repo.setErrorCalls)
}
func TestOpenAIStreamAccessStateAppliesAccountHealthBeforeFailover(t *testing.T) {
svc := &OpenAIGatewayService{}
account := &Account{ID: 919, Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
payload := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`)
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "workspace is suspended", nil)
require.Equal(t, http.StatusForbidden, status)
require.True(t, disabled)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestOpenAIStreamPairedFailureAppliesAccountSideEffectsOnce(t *testing.T) {
const upstream = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" +
"data: {\"type\":\"error\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}\n\n" +
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}}\n\n"
t.Run("native", func(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &openAIStream403AccountRepo{}
svc := &OpenAIGatewayService{
cfg: &config.Config{},
toolCorrector: NewCodexToolCorrector(),
rateLimitService: &RateLimitService{accountRepo: repo},
}
account := &Account{ID: 921, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
recorder := newOpenAIResponseFlushRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstream)),
}
result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
require.Error(t, err)
require.NotNil(t, result)
require.Equal(t, 1, repo.setErrorCalls)
})
t.Run("passthrough", func(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &openAIStream403AccountRepo{}
svc := &OpenAIGatewayService{
cfg: &config.Config{},
rateLimitService: &RateLimitService{accountRepo: repo},
}
account := &Account{ID: 922, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
writer := &passthroughFlushTestWriter{
ResponseWriter: c.Writer,
recorder: recorder,
failAfterWrites: -1,
}
c.Writer = writer
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstream)),
}
result, err := svc.handleStreamingResponsePassthrough(
context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5",
)
require.Error(t, err)
require.NotNil(t, result)
require.Equal(t, 1, repo.setErrorCalls)
})
}
func TestOpenAIStreamOAuthLike429GetsDeadlineWithoutImmediateRuntimeBlock(t *testing.T) {
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
t.Run(accountType, func(t *testing.T) {
svc := &OpenAIGatewayService{}
account := &Account{ID: 920, Platform: PlatformOpenAI, Type: accountType}
payload := []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "slow down", nil)
err := svc.newOpenAIAccountFailoverError(account, status, nil, payload, "slow down", disabled, false)
require.Equal(t, http.StatusTooManyRequests, status)
require.False(t, disabled)
require.True(t, err.RetryableOnSameAccount)
require.False(t, err.SameAccountRetryDeadline.IsZero())
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
})
}
}
@@ -12,6 +12,10 @@ import (
const (
openAIAccountStateUpdateTimeout = 5 * time.Second
openAIOAuth429FallbackCooldown = 5 * time.Second
openAIOAuth429RetryWindow = 2 * time.Minute
openAIOAuth429RetryDelay = 500 * time.Millisecond
openAIOAuth429MaxRetryDelay = 8 * time.Second
openAIOAuth429MaxAccountAttempts = 3
openAIStopSchedulingBridgeCooldown = 2 * time.Minute
openAIOAuth429StormWindow = 10 * time.Second
openAIOAuth429StormThreshold = 20
@@ -34,7 +38,7 @@ func openAIAccountStateContext(ctx context.Context) (context.Context, context.Ca
}
func isOpenAIOAuthAccount(account *Account) bool {
return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth
return account != nil && account.IsOpenAIOAuthLike()
}
func isGrokOAuthAccount(account *Account) bool {
@@ -62,6 +66,19 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
}
stateCtx, cancel := openAIAccountStateContext(ctx)
defer cancel()
if account != nil && account.Platform == PlatformOpenAI && isOpenAIUpstreamAccessStateError("", responseBody) {
message := "OpenAI upstream account or workspace is unavailable"
if upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(responseBody)); upstreamMsg != "" {
message = upstreamMsg
}
if s != nil && s.rateLimitService != nil {
s.rateLimitService.handleAuthError(stateCtx, account, message)
}
if s != nil {
s.BlockAccountScheduling(account, time.Time{}, "openai_access_state")
}
return true
}
if account != nil && account.Platform == PlatformOpenAI && isOpenAIContextWindowError("", responseBody) {
return false
@@ -149,6 +166,9 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
return
}
s.recordOpenAIOAuth429()
if s.openAIOAuth429RetryWindowActive(account) {
return
}
cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown)
if s.rateLimitService != nil {
@@ -163,6 +183,75 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
}
}
s.BlockAccountScheduling(account, cooldownUntil, "429")
s.openaiOAuth429RetryStartedAt.Delete(account.ID)
}
func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool {
if shouldDisable || statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) || account.IsShadow() {
return false
}
// markOpenAIOAuth429RateLimited parks the account once the window expires.
// Do not accidentally create a fresh window after that transition.
if s.isOpenAIAccountRuntimeBlocked(account) {
return false
}
return s.openAIOAuth429RetryWindowActive(account)
}
// ShouldRetryOpenAIOAuth429 lets RateLimitService defer persistent account
// cooldown until the gateway's same-account retry window is exhausted.
func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, _ http.Header, _ []byte) bool {
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() || s.isOpenAIAccountRuntimeBlocked(account) {
return false
}
return s.openAIOAuth429RetryWindowActive(account)
}
func (s *OpenAIGatewayService) openAIOAuth429RetryWindowActive(account *Account) bool {
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() {
return false
}
now := time.Now()
value, _ := s.openaiOAuth429RetryStartedAt.LoadOrStore(account.ID, now)
startedAt, ok := value.(time.Time)
if !ok {
s.openaiOAuth429RetryStartedAt.Store(account.ID, now)
startedAt = now
}
return now.Before(startedAt.Add(openAIOAuth429RetryWindow))
}
func (s *OpenAIGatewayService) openAIOAuth429RetryDeadline(account *Account) time.Time {
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() {
return time.Time{}
}
value, ok := s.openaiOAuth429RetryStartedAt.Load(account.ID)
if !ok {
return time.Time{}
}
startedAt, ok := value.(time.Time)
if !ok {
return time.Time{}
}
return startedAt.Add(openAIOAuth429RetryWindow)
}
func openAIOAuth429SameAccountRetryDelay(headers http.Header, deadline time.Time) time.Duration {
delay := openAIOAuth429RetryDelay
now := time.Now()
if resetAt := parseRetryAfterResetTime(headers, now); resetAt != nil && resetAt.After(now) {
delay = resetAt.Sub(now)
}
if delay > openAIOAuth429MaxRetryDelay {
delay = openAIOAuth429MaxRetryDelay
}
if remaining := time.Until(deadline); !deadline.IsZero() && delay > remaining {
delay = remaining
}
if delay < 0 {
return 0
}
return delay
}
func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) {
@@ -228,6 +317,7 @@ func (s *OpenAIGatewayService) ClearAccountSchedulingBlock(accountID int64) {
mu.Lock()
defer mu.Unlock()
s.openaiAccountRuntimeBlockUntil.Delete(accountID)
s.openaiOAuth429RetryStartedAt.Delete(accountID)
s.openaiAccountRuntimeBlockGeneration.Store(accountID, s.openaiAccountRuntimeBlockSequence.Add(1))
}
@@ -273,6 +363,9 @@ func canonicalOpenAIAccountSchedulingModel(account *Account, requestedModel stri
if account == nil || model == "" {
return model
}
if account.IsOpenAI() {
return resolveOpenAIAccountUpstreamModelForRequest(account, model, false)
}
if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" {
return mapped
}
@@ -368,5 +461,8 @@ func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account
if statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) {
return false
}
return s.isOpenAIOAuth429Storm()
// Each OpenAI OAuth candidate has already consumed its full same-account
// retry window before reaching this switch point. A global storm is useful
// telemetry, but must not prevent trying the bounded next-account budget.
return failedSwitches >= openAIOAuth429MaxAccountAttempts
}
@@ -13,18 +13,57 @@ import (
"github.com/stretchr/testify/require"
)
func TestOpenAI429FastPath_MarksOAuthAccountCoolingDown(t *testing.T) {
svc := &OpenAIGatewayService{}
type oauth429RateLimitRepo struct {
AccountRepository
setRateLimitedCalls int
}
func (r *oauth429RateLimitRepo) SetRateLimited(context.Context, int64, time.Time) error {
r.setRateLimitedCalls++
return nil
}
func TestOpenAI429FastPath_KeepsOAuthAccountSchedulableDuringRetryWindow(t *testing.T) {
repo := &oauth429RateLimitRepo{}
rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
rateLimits.SetAccountRuntimeBlocker(svc)
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
setupTokenAccount := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
grokOAuthAccount := &Account{ID: 45, Platform: PlatformGrok, Type: AccountTypeOAuth}
shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, nil)
apiKeyShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), apiKeyAccount, http.StatusTooManyRequests, http.Header{}, nil)
require.False(t, shouldDisable)
require.False(t, apiKeyShouldDisable)
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.True(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount), "API-key 429 keeps the existing scheduler cooldown behavior")
require.Equal(t, 1, repo.setRateLimitedCalls, "only the API-key 429 should persist a scheduler block")
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(setupTokenAccount, http.StatusTooManyRequests, false))
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(apiKeyAccount, http.StatusTooManyRequests, false))
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(grokOAuthAccount, http.StatusTooManyRequests, false))
require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(account), time.Second)
require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(setupTokenAccount), time.Second)
}
func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) {
svc := &OpenAIGatewayService{}
account := &Account{ID: 420, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second))
svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, nil)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.False(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount))
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
}
func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) {
deadline := time.Now().Add(openAIOAuth429RetryWindow)
require.Equal(t, openAIOAuth429RetryDelay, openAIOAuth429SameAccountRetryDelay(nil, deadline))
require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline))
}
// TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429,
@@ -50,7 +89,7 @@ func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) {
svc.markOpenAIOAuth429RateLimited(context.Background(), normal, headers, nil)
require.False(t, svc.isOpenAIAccountRuntimeBlocked(shadow), "spark shadow must not be runtime-blocked by /responses global 429")
require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account should still be runtime-blocked")
require.False(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account stays schedulable during its retry window")
}
func TestOpenAIRuntimeBlock_AppliesToOpenAIAPIKeyWhenRateLimitServiceStopsScheduling(t *testing.T) {
@@ -347,7 +386,8 @@ func TestOpenAIOAuth429_NonmatchingModelTempRuleKeepsAccountRuntimeBlock(t *test
)
require.False(t, shouldDisable)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
require.Empty(t, repo.modelRateLimitCalls)
}
@@ -398,7 +438,7 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) {
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) {
func TestShouldStopOpenAIOAuth429Failover_AfterBoundedFullWindows(t *testing.T) {
svc := &OpenAIGatewayService{}
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
@@ -406,11 +446,9 @@ func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) {
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
for i := 0; i < openAIOAuth429StormThreshold; i++ {
svc.recordOpenAIOAuth429()
}
require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 2, &state))
require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 3, &state))
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(apiKeyAccount, http.StatusTooManyRequests, 1, &state))
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusInternalServerError, 1, &state))
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 0, &state))
@@ -85,7 +85,7 @@ func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T)
}
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil)
svc.ReportOpenAIAccountScheduleResult(account, canonicalModel, true, nil)
require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
}
@@ -2299,15 +2299,38 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou
return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport
}
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) {
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(account *Account, model string, success bool, firstTokenMs *int, observedErr ...error) bool {
if account == nil {
return false
}
accountID := account.ID
healthTripped := false
if s != nil && s.rateLimitService != nil {
if success {
s.rateLimitService.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), account)
} else if len(observedErr) > 0 && observedErr[0] != nil {
healthTripped = s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, observedErr[0])
}
}
if success {
s.openaiOAuth429RetryStartedAt.Delete(accountID)
s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model))
}
scheduler := s.getOpenAIAccountScheduler(context.Background())
if scheduler == nil {
return
return healthTripped
}
scheduler.ReportResult(accountID, success, firstTokenMs)
return healthTripped
}
// ObserveOpenAIAccountHealthFailure records failures that cannot reach the
// scheduler-result path, for example after semantic response bytes were sent.
func (s *OpenAIGatewayService) ObserveOpenAIAccountHealthFailure(ctx context.Context, account *Account, observedErr error) bool {
if s == nil || s.rateLimitService == nil || account == nil || observedErr == nil {
return false
}
return s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(ctx, account, observedErr)
}
func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() {
@@ -2663,7 +2686,7 @@ func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthScheduli
continue
}
factors[account.ID] = openAIUpstreamCostNeutralFactor
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() {
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() {
continue
}
eligibleCount++
@@ -2727,7 +2750,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS
// 与 openAIUpstreamCostFactors 使用同一道平台门控:只有 OpenAI 平台账号
// 的倍率参与 legacy 低倍率优先排序。上游自报倍率来自中转方,不能让它对
// 其他平台的调度产生影响——否则自报低价即可吸走流量,而实际结算走本地倍率。
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() {
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() {
continue
}
rate, ok := openAISchedulingRate(account, now, oauthSchedulingRateMultiplier)
@@ -2745,7 +2768,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS
}
func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier float64) (float64, bool) {
if account != nil && account.IsOpenAIOAuth() {
if account != nil && account.IsOpenAIOAuthLike() {
return oauthSchedulingRateMultiplier, true
}
return openAIFreshUpstreamBillingRate(account, now)
@@ -1455,7 +1455,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test
svc := &OpenAIGatewayService{}
ttft := 120
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft)
svc.RecordOpenAIAccountSwitch()
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
@@ -2687,7 +2687,7 @@ func TestReportOpenAIAccountScheduleResult_SuccessClearsModelTransientState(t *t
svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond))
require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil)
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 21636}, "gpt-5.5", true, nil)
require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
}
@@ -3147,7 +3147,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) {
selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
require.NoError(t, err)
require.NotNil(t, selection)
svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120))
svc.ReportOpenAIAccountScheduleResult(&account, "", true, intPtrForTest(120))
svc.RecordOpenAIAccountSwitch()
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
@@ -3484,7 +3484,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) {
svc := &OpenAIGatewayService{}
ttft := 120
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft)
svc.RecordOpenAIAccountSwitch()
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot)
+22 -11
View File
@@ -58,6 +58,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil {
return nil, err
}
SetOpsUpstreamModel(c, upstreamModel)
// Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex
// /responses,但会被 standalone /alpha/search 的 access enforcement
@@ -97,13 +98,16 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
// 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。
shouldDisable := false
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
}
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
}
if isOpenAIUpstreamAccessStateError(upstreamMessage, respBody) {
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
}
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
}
}
@@ -172,13 +176,16 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
// 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。
shouldDisable := false
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
}
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
}
if isOpenAIUpstreamAccessStateError(upstreamMessage, respBody) {
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
}
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
}
}
@@ -211,6 +218,10 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
}, nil
}
func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) string {
return canonicalOpenAIAccountSchedulingModel(account, requestedModel)
}
func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
if err != nil {
@@ -653,7 +664,7 @@ func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, e
return "", fmt.Errorf("account is required")
}
switch account.Type {
case AccountTypeOAuth:
case AccountTypeOAuth, AccountTypeSetupToken:
return chatgptCodexAlphaSearchURL, nil
case AccountTypeAPIKey:
baseURL := account.GetOpenAIBaseURL()
@@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
@@ -300,11 +301,144 @@ func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) {
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
require.Empty(t, failoverErr.Stage)
require.Empty(t, failoverErr.Scope)
require.Empty(t, failoverErr.Reason)
require.Zero(t, failoverErr.ClientStatusCode)
require.Empty(t, failoverErr.ClientMessage)
require.Nil(t, failoverErr.ResponseHeaders)
require.Equal(t, openAIPlatformAlphaSearchURL, upstream.lastReq.URL.String())
require.False(t, c.Writer.Written())
require.Empty(t, recorder.Body.String())
}
func TestForwardAlphaSearchSetupToken429CarriesSameAccountRetryWindow(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{
"Content-Type": []string{"application/json"},
"Retry-After": []string{"1"},
"X-Request-Id": []string{"req_alpha_oauth_429"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)),
}}
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 81,
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
},
}
startedAt := time.Now()
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay)
require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second)
require.Equal(t, "req_alpha_oauth_429", failoverErr.ResponseHeaders.Get("x-request-id"))
require.False(t, c.Writer.Written())
}
func TestForwardAlphaSearchAccessStateUsesTypedFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_alpha_access_state"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`)),
}}
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 11,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
},
}
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.Nil(t, result)
assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_access_state")
require.Equal(t, chatgptCodexAlphaSearchURL, upstream.lastReq.URL.String())
require.False(t, c.Writer.Written())
}
func TestForwardAlphaSearchPATFallbackAccessStateUsesTypedFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_alpha_pat_access_state"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"account_disabled","message":"Account is disabled"}}`)),
}}
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 12,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "at-test-token",
"auth_mode": OpenAIAuthModePersonalAccessToken,
"chatgpt_account_id": "chatgpt-account",
},
}
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.Nil(t, result)
assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_pat_access_state")
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
require.False(t, c.Writer.Written())
}
func assertOpenAIAlphaSearchAccessStateFailover(t *testing.T, err error, requestID string) {
t.Helper()
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
require.False(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, requestID, failoverErr.ResponseHeaders.Get("x-request-id"))
}
func TestForwardAlphaSearchUnauthorizedDoesNotMarkAccountError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
@@ -482,6 +616,14 @@ func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
}
func TestOpenAIAlphaSearchSchedulingModelUsesCanonicalAccountMapping(t *testing.T) {
account := &Account{Credentials: map[string]any{
"model_mapping": map[string]any{"client-visible": "canonical-upstream"},
}}
require.Equal(t, "canonical-upstream", openAIAlphaSearchSchedulingModel(account, "client-visible"))
require.Equal(t, "unmapped", openAIAlphaSearchSchedulingModel(account, "unmapped"))
}
func TestSanitizeOpenAIAlphaSearchBody_RemovesResponsesOnlyFields(t *testing.T) {
body := []byte(`{"id":"search-session","store":false,"prompt_cache_key":"cache","commands":{"search_query":[{"q":"news"}]}}`)
@@ -0,0 +1,138 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"go.uber.org/zap"
)
const openAIAPIKeyHealthBreakerReason = "openai_apikey_health_breaker"
func isOpenAIAPIKeyHealthBreakerAccount(account *Account) bool {
return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && account.IsPoolMode()
}
func classifyOpenAIAPIKeyHealthFailure(err error) (int, []byte, bool) {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return 0, nil, false
}
var failoverErr *UpstreamFailoverError
if errors.As(err, &failoverErr) {
// These failures already have dedicated recovery/state handling or are not
// attributable to the selected account.
if failoverErr.IsCredentialFailure() ||
failoverErr.RequestScopedTransient ||
failoverErr.RetryableOnSameAccount ||
failoverErr.Scope == GatewayFailureScopeRequest ||
failoverErr.Scope == GatewayFailureScopeProvider {
return failoverErr.StatusCode, failoverErr.ResponseBody, false
}
if failoverErr.StatusCode == http.StatusTooManyRequests || failoverErr.StatusCode >= http.StatusInternalServerError {
return failoverErr.StatusCode, failoverErr.ResponseBody, true
}
return failoverErr.StatusCode, failoverErr.ResponseBody, false
}
var imageErr *OpenAIImagesUpstreamError
if errors.As(err, &imageErr) {
if imageErr.StatusCode == http.StatusTooManyRequests || imageErr.StatusCode >= http.StatusInternalServerError {
return imageErr.StatusCode, []byte(strings.TrimSpace(imageErr.Message)), true
}
}
return 0, nil, false
}
func (s *RateLimitService) ObserveOpenAIAPIKeyHealthFailure(ctx context.Context, account *Account, upstreamErr error) bool {
if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || s.accountRepo == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
return false
}
statusCode, responseBody, eligible := classifyOpenAIAPIKeyHealthFailure(upstreamErr)
if !eligible {
return false
}
settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
if err != nil {
logger.L().Warn("openai.apikey_health_breaker_settings_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return false
}
if settings == nil || !settings.Enabled {
return false
}
count, tripped, err := s.openAIAPIKeyHealth.RecordOpenAIAPIKeyHealthFailure(ctx, account.ID, settings.WindowMinutes, settings.FailureThreshold)
if err != nil {
logger.L().Warn("openai.apikey_health_breaker_record_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return false
}
if !tripped {
return false
}
now := time.Now()
until := now.Add(time.Duration(settings.CooldownMinutes) * time.Minute)
state := &TempUnschedState{
UntilUnix: until.Unix(),
TriggeredAtUnix: now.Unix(),
StatusCode: statusCode,
MatchedKeyword: openAIAPIKeyHealthBreakerReason,
RuleIndex: -1,
ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
TriggerCount: count,
TriggerThreshold: settings.FailureThreshold,
TriggerWindowMinutes: settings.WindowMinutes,
}
reasonBytes, _ := json.Marshal(state)
reason := string(reasonBytes)
if reason == "" {
reason = fmt.Sprintf("%s: %d failures in %d minute(s)", openAIAPIKeyHealthBreakerReason, count, settings.WindowMinutes)
}
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
defer cancel()
if err := s.accountRepo.SetTempUnschedulable(persistCtx, account.ID, until, reason); err != nil {
logger.L().Warn("openai.apikey_health_breaker_persist_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return false
}
if account.TempUnschedulableUntil == nil || account.TempUnschedulableUntil.Before(until) {
account.TempUnschedulableUntil = &until
account.TempUnschedulableReason = reason
}
s.notifyAccountSchedulingBlocked(account, until, openAIAPIKeyHealthBreakerReason)
if s.tempUnschedCache != nil {
if err := s.tempUnschedCache.SetTempUnsched(persistCtx, account.ID, state); err != nil {
logger.L().Warn("openai.apikey_health_breaker_cache_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
logger.L().Warn("openai.apikey_health_breaker_tripped",
zap.Int64("account_id", account.ID),
zap.Int64("failure_count", count),
zap.Int("failure_threshold", settings.FailureThreshold),
zap.Int("window_minutes", settings.WindowMinutes),
zap.Int("cooldown_minutes", settings.CooldownMinutes),
zap.Int("upstream_status", statusCode),
zap.Time("until", until),
)
return true
}
func (s *RateLimitService) ObserveOpenAIAPIKeyHealthSuccess(ctx context.Context, account *Account) {
if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
return
}
settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
if err != nil || settings == nil || !settings.Enabled {
return
}
if err := s.openAIAPIKeyHealth.ResetOpenAIAPIKeyHealthFailures(ctx, account.ID); err != nil {
logger.L().Warn("openai.apikey_health_breaker_reset_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
@@ -0,0 +1,142 @@
package service
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type openAIAPIKeyHealthSettingRepo struct {
SettingRepository
value string
}
func (r *openAIAPIKeyHealthSettingRepo) GetValue(context.Context, string) (string, error) {
return r.value, nil
}
type openAIAPIKeyHealthAccountRepo struct {
AccountRepository
setCalls int
reason string
}
func (r *openAIAPIKeyHealthAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
r.setCalls++
r.reason = reason
return nil
}
type openAIAPIKeyHealthCacheStub struct {
TempUnschedCache
recordCalls int
resetCalls int
setCalls int
tripped bool
}
func (c *openAIAPIKeyHealthCacheStub) RecordOpenAIAPIKeyHealthFailure(context.Context, int64, int, int) (int64, bool, error) {
c.recordCalls++
return 3, c.tripped, nil
}
func (c *openAIAPIKeyHealthCacheStub) ResetOpenAIAPIKeyHealthFailures(context.Context, int64) error {
c.resetCalls++
return nil
}
func (c *openAIAPIKeyHealthCacheStub) SetTempUnsched(context.Context, int64, *TempUnschedState) error {
c.setCalls++
return nil
}
type openAIAPIKeyHealthRuntimeBlocker struct{ calls int }
func (b *openAIAPIKeyHealthRuntimeBlocker) BlockAccountScheduling(*Account, time.Time, string) {
b.calls++
}
func (*openAIAPIKeyHealthRuntimeBlocker) ClearAccountSchedulingBlock(int64) {}
func openAIHealthPoolAccount() *Account {
return &Account{
ID: 42,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"pool_mode": true,
},
}
}
func TestClassifyOpenAIAPIKeyHealthFailureExclusions(t *testing.T) {
tests := []struct {
name string
err error
eligible bool
}{
{name: "account attributed 502", err: &UpstreamFailoverError{StatusCode: http.StatusBadGateway}, eligible: true},
{name: "request scoped capacity", err: &UpstreamFailoverError{StatusCode: 529, RequestScopedTransient: true}},
{name: "provider scoped overload", err: &UpstreamFailoverError{StatusCode: 529, Scope: GatewayFailureScopeProvider}},
{name: "dedicated same account retry", err: &UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, RetryableOnSameAccount: true}},
{name: "credential disable path", err: &UpstreamFailoverError{StatusCode: http.StatusUnauthorized, Stage: GatewayFailureStageAccountAuth, Scope: GatewayFailureScopeAccount}},
{name: "client request", err: &UpstreamFailoverError{StatusCode: http.StatusBadRequest}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, eligible := classifyOpenAIAPIKeyHealthFailure(tt.err)
require.Equal(t, tt.eligible, eligible)
})
}
}
func TestOpenAIAPIKeyHealthBreakerDefaultDisabled(t *testing.T) {
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{}, &config.Config{})
cache := &openAIAPIKeyHealthCacheStub{tripped: true}
svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
svc.SetSettingService(settings)
svc.SetOpenAIAPIKeyHealthCache(cache)
require.False(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), openAIHealthPoolAccount(), &UpstreamFailoverError{StatusCode: http.StatusBadGateway}))
require.Zero(t, cache.recordCalls)
}
func TestOpenAIAPIKeyHealthBreakerTripsPersistedAndRuntimeState(t *testing.T) {
encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
require.NoError(t, err)
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{})
cache := &openAIAPIKeyHealthCacheStub{tripped: true}
repo := &openAIAPIKeyHealthAccountRepo{}
blocker := &openAIAPIKeyHealthRuntimeBlocker{}
svc := NewRateLimitService(repo, nil, &config.Config{}, nil, cache)
svc.SetSettingService(settings)
svc.SetOpenAIAPIKeyHealthCache(cache)
svc.SetAccountRuntimeBlocker(blocker)
account := openAIHealthPoolAccount()
require.True(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, ResponseBody: []byte(`{"error":"upstream"}`)}))
require.Equal(t, 1, cache.recordCalls)
require.Equal(t, 1, cache.setCalls)
require.Equal(t, 1, repo.setCalls)
require.Equal(t, 1, blocker.calls)
require.NotNil(t, account.TempUnschedulableUntil)
require.Contains(t, repo.reason, openAIAPIKeyHealthBreakerReason)
}
func TestOpenAIAPIKeyHealthSuccessResetsOnlyEligiblePoolAccount(t *testing.T) {
encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
require.NoError(t, err)
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{})
cache := &openAIAPIKeyHealthCacheStub{}
svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
svc.SetSettingService(settings)
svc.SetOpenAIAPIKeyHealthCache(cache)
svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), openAIHealthPoolAccount())
svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
require.Equal(t, 1, cache.resetCalls)
}
@@ -195,6 +195,8 @@ func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.RetryableOnSameAccount)
require.True(t, failoverErr.RequestScopedTransient)
require.Equal(t, http.StatusServiceUnavailable, failoverErr.ClientStatusCode)
require.Contains(t, failoverErr.ClientMessage, "servers are currently overloaded")
require.False(t, c.Writer.Written())
require.Empty(t, rec.Body.String())
})
@@ -244,8 +246,8 @@ func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *test
}
// 流中途(已有真实输出)降载时无法再 failover,此时必须把降载码改写为客户端
// 可重试的 server_error 再转发——Codex 对 server_is_overloaded/slow_down 判致命
// 并终止会话,对其余错误码执行内置退避重试。消息原样保留。
// 可重试的 server_error 再通过唯一 response.failed 终态转发——Codex 对
// server_is_overloaded/slow_down 判致命并终止会话,对其余错误码执行内置退避重试。
func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) {
gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
@@ -285,7 +287,9 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T)
body := rec.Body.String()
require.Contains(t, body, "partial")
require.Contains(t, body, "event: response.failed")
require.NotContains(t, body, "event: error")
require.Equal(t, 1, strings.Count(body, "event: response.failed"))
require.Equal(t, 1, strings.Count(body, `"code":"server_error"`))
require.Contains(t, body, `"code":"server_error"`)
require.NotContains(t, body, "server_is_overloaded")
require.Contains(t, body, "Our servers are currently overloaded")
@@ -6,7 +6,7 @@ import (
)
func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) {
if headers == nil || account == nil || !account.IsOpenAIOAuth() {
if headers == nil || account == nil || !account.IsOpenAIOAuthLike() {
return
}
if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" {
@@ -32,7 +32,7 @@ func stageCodexFingerprintIDs(c *gin.Context, ids *codexFingerprintIDs) {
}
func stagedCodexFingerprintIDs(c *gin.Context, account *Account) *codexFingerprintIDs {
if c == nil || account == nil || account.Type != AccountTypeOAuth {
if c == nil || account == nil || !account.UsesOpenAICodexProtocol() {
return nil
}
value, ok := c.Get(codexFingerprintIDsContextKey)
@@ -141,7 +141,7 @@ func codexFingerprintSeed(extra map[string]any) (string, bool) {
func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra map[string]any) map[string]any {
prepared := stripCodexFingerprintSeed(extra)
if platform != PlatformOpenAI || accountType != AccountTypeOAuth || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) {
if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) {
return prepared
}
if prepared == nil {
@@ -153,7 +153,7 @@ func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra m
func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]any) map[string]any {
prepared := stripCodexFingerprintSeed(extra)
if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
if account == nil || !account.IsOpenAIOAuthLike() {
return prepared
}
if seed, ok := codexFingerprintSeed(account.Extra); ok {
@@ -203,7 +203,7 @@ func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) boo
// 的 A/B 实测。上游的配额判定策略不可观测,因此这里取兼容安全的一侧:
// 不显式 opt-in 就保持 v0.1.175 之前的客户端身份(#5610)。
func (a *Account) GetCodexFingerprintMode() codexFingerprintMode {
if a == nil || !a.IsOpenAIOAuth() {
if a == nil || !a.IsOpenAIOAuthLike() {
return codexFingerprintOff
}
return codexFingerprintModeFromExtra(a.Extra)
@@ -64,6 +64,8 @@ func TestGetCodexFingerprintMode(t *testing.T) {
}{
{"nil 账号", nil, codexFingerprintOff},
{"非 OAuth 账号", &Account{Platform: PlatformOpenAI, Type: "api_key"}, codexFingerprintOff},
{"OpenAI setup token", &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintSession},
{"Anthropic setup token", &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintOff},
// 收敛是显式 opt-in:缺省/空/非法一律 off(#5610)。存量账号普遍没有这个
// extra 键,升级不得把它们静默切进收敛。
{"无 extra 默认 off", newTestOAuthAccount(1, nil), codexFingerprintOff},
@@ -6,12 +6,15 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
codexReservedPythonToolName = "python"
codexPythonToolAlias = "python__sub2api"
codexToolNameReverseKey = "openai_codex_tool_name_reverse"
codexToolNameSessionKey = "openai_codex_tool_name_session_reverse"
)
type codexToolNameField struct {
@@ -88,25 +91,37 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam
continue
}
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
if toolType != "namespace" {
if toolType == "function" {
appendName(tool, "name")
if function, ok := tool["function"].(map[string]any); ok {
appendName(function, "name")
}
}
if function, ok := tool["function"].(map[string]any); ok {
appendName(function, "name")
if toolType == "namespace" {
collectTools(tool["tools"])
}
collectTools(tool["tools"])
}
}
collectTools(reqBody["tools"])
collectTools(reqBody["functions"])
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
if !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "namespace") {
appendName(choice, "name")
}
if function, ok := choice["function"].(map[string]any); ok {
if functions, ok := reqBody["functions"].([]any); ok {
for _, raw := range functions {
function, _ := raw.(map[string]any)
appendName(function, "name")
}
}
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(reqBody["type"])), "session.update") {
if session, ok := reqBody["session"].(map[string]any); ok {
collectTools(session["tools"])
}
}
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") {
appendName(choice, "name")
if function, ok := choice["function"].(map[string]any); ok {
appendName(function, "name")
}
}
}
if input, ok := reqBody["input"].([]any); ok {
for _, raw := range input {
item, ok := raw.(map[string]any)
@@ -117,7 +132,7 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam
if typ == "additional_tools" {
collectTools(item["tools"])
}
if strings.HasSuffix(typ, "_call") || typ == "tool_call" {
if typ == "function_call" {
appendName(item, "name")
if function, ok := item["function"].(map[string]any); ok {
appendName(function, "name")
@@ -133,7 +148,7 @@ func aliasOpenAIOAuthReservedToolNamesBody(body []byte) ([]byte, map[string]stri
return body, nil, false, nil
}
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, nil, false, fmt.Errorf("decode OAuth reserved tool names: %w", err)
}
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
@@ -174,6 +189,14 @@ func containsASCIIFold(haystack, needle []byte) bool {
}
func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
if c == nil {
return
}
storeCodexToolNameReverse(c, codexToolNameReverseKey, reverse)
storeCodexToolNameReverse(c, codexToolNameSessionKey, nil)
}
func storeCodexToolNameReverse(c *gin.Context, key string, reverse map[string]string) {
if c == nil {
return
}
@@ -181,14 +204,32 @@ func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
for aliased, original := range reverse {
copyMap[aliased] = original
}
c.Set(codexToolNameReverseKey, copyMap)
c.Set(key, copyMap)
}
func mergeCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
if c == nil || len(reverse) == 0 {
return
}
merged := make(map[string]string, len(reverse)+len(codexToolNameReverseFromContext(c)))
for aliased, original := range codexToolNameReverseFromContext(c) {
merged[aliased] = original
}
for aliased, original := range reverse {
merged[aliased] = original
}
storeCodexToolNameReverse(c, codexToolNameReverseKey, merged)
}
func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
return codexToolNameReverseForKey(c, codexToolNameReverseKey)
}
func codexToolNameReverseForKey(c *gin.Context, key string) map[string]string {
if c == nil {
return nil
}
raw, ok := c.Get(codexToolNameReverseKey)
raw, ok := c.Get(key)
if !ok {
return nil
}
@@ -196,12 +237,59 @@ func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
return reverse
}
// updateCodexToolNameReverseForWSFrame keeps the active turn isolated from
// session updates that may arrive while that turn is still streaming.
func updateCodexToolNameReverseForWSFrame(c *gin.Context, frame []byte, reverse map[string]string) {
if c == nil {
return
}
eventType := strings.TrimSpace(gjson.GetBytes(frame, "type").String())
switch eventType {
case "session.update":
if gjson.GetBytes(frame, "session.tools").Exists() {
storeCodexToolNameReverse(c, codexToolNameSessionKey, reverse)
}
case "response.create", "":
active := reverse
if !openAIWSFrameHasExplicitToolDeclarations(frame) {
active = mergeCodexToolNameReverseMaps(
codexToolNameReverseForKey(c, codexToolNameSessionKey),
reverse,
)
}
storeCodexToolNameReverse(c, codexToolNameReverseKey, active)
}
}
func openAIWSFrameHasExplicitToolDeclarations(frame []byte) bool {
if gjson.GetBytes(frame, "tools").Exists() {
return true
}
for _, item := range gjson.GetBytes(frame, "input").Array() {
if strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "additional_tools") && item.Get("tools").Exists() {
return true
}
}
return false
}
func mergeCodexToolNameReverseMaps(base, overlay map[string]string) map[string]string {
merged := make(map[string]string, len(base)+len(overlay))
for aliased, original := range base {
merged[aliased] = original
}
for aliased, original := range overlay {
merged[aliased] = original
}
return merged
}
func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte {
if len(data) == 0 || len(reverse) == 0 || !json.Valid(data) {
return data
}
var decoded any
if err := json.Unmarshal(data, &decoded); err != nil {
if err := decodeOpenAIJSONUseNumber(data, &decoded); err != nil {
return data
}
if !restoreCodexToolNameFields(decoded, reverse) {
@@ -215,30 +303,153 @@ func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte
}
func restoreCodexToolNamesFromContext(c *gin.Context, data []byte) []byte {
return restoreCodexToolNamesInJSON(data, codexToolNameReverseFromContext(c))
reverse := codexToolNameReverseFromContext(c)
switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
case "session.created", "session.updated":
reverse = codexToolNameReverseForKey(c, codexToolNameSessionKey)
}
return restoreCodexToolNamesInJSON(data, reverse)
}
func restoreCodexToolNamesFromSSEContext(c *gin.Context, data []byte, eventType string) []byte {
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "" || strings.TrimSpace(eventType) == "" {
return restoreCodexToolNamesFromContext(c, data)
}
compat := []byte(openAICompatPayloadWithEventType(string(data), eventType))
restored := restoreCodexToolNamesFromContext(c, compat)
if string(restored) == string(compat) {
return data
}
withoutSyntheticType, err := sjson.DeleteBytes(restored, "type")
if err != nil {
return restored
}
return withoutSyntheticType
}
func restoreCodexToolNameFields(value any, reverse map[string]string) bool {
root, ok := value.(map[string]any)
if !ok {
return false
}
changed := false
switch typed := value.(type) {
case map[string]any:
if name, ok := typed["name"].(string); ok {
restoreItem := func(raw any) {
item, ok := raw.(map[string]any)
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(item["type"])), "function_call") {
return
}
name, _ := item["name"].(string)
if original, exists := reverse[name]; exists {
item["name"] = original
changed = true
}
}
restoreOutput := func(raw any) {
output, _ := raw.([]any)
for _, item := range output {
restoreItem(item)
}
}
restoreResponse := func(raw any) {
response, ok := raw.(map[string]any)
if ok {
restoreOutput(response["output"])
}
}
restoreFunction := func(raw any) {
function, ok := raw.(map[string]any)
if !ok {
return
}
name, _ := function["name"].(string)
if original, exists := reverse[name]; exists {
function["name"] = original
changed = true
}
}
restoreChatToolCalls := func(raw any) {
toolCalls, _ := raw.([]any)
for _, rawCall := range toolCalls {
call, ok := rawCall.(map[string]any)
if !ok {
continue
}
callType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(call["type"])))
if callType == "" || callType == "function" {
restoreFunction(call["function"])
}
}
}
restoreMessageContent := func(raw any) {
content, _ := raw.([]any)
for _, rawBlock := range content {
block, ok := rawBlock.(map[string]any)
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
continue
}
name, _ := block["name"].(string)
if original, exists := reverse[name]; exists {
typed["name"] = original
changed = true
}
}
for _, child := range typed {
if restoreCodexToolNameFields(child, reverse) {
changed = true
}
}
case []any:
for _, child := range typed {
if restoreCodexToolNameFields(child, reverse) {
block["name"] = original
changed = true
}
}
}
var restoreTools func(any)
restoreTools = func(raw any) {
tools, _ := raw.([]any)
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
continue
}
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
if toolType == "function" {
name, _ := tool["name"].(string)
if original, exists := reverse[name]; exists {
tool["name"] = original
changed = true
}
}
if toolType == "namespace" {
restoreTools(tool["tools"])
}
}
}
eventType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(root["type"])))
switch eventType {
case "response.output_item.added", "response.output_item.done":
restoreItem(root["item"])
case "response.created", "response.in_progress", "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
restoreResponse(root["response"])
case "session.created", "session.updated":
if session, ok := root["session"].(map[string]any); ok {
restoreTools(session["tools"])
}
}
if _, hasOutput := root["output"]; hasOutput {
restoreOutput(root["output"])
}
if choices, ok := root["choices"].([]any); ok {
for _, rawChoice := range choices {
choice, ok := rawChoice.(map[string]any)
if !ok {
continue
}
for _, key := range []string{"message", "delta"} {
if message, ok := choice[key].(map[string]any); ok {
restoreChatToolCalls(message["tool_calls"])
}
}
}
}
restoreMessageContent(root["content"])
if block, ok := root["content_block"].(map[string]any); ok && strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
name, _ := block["name"].(string)
if original, exists := reverse[name]; exists {
block["name"] = original
changed = true
}
}
return changed
}
@@ -93,3 +93,121 @@ func TestRestoreCodexToolNamesFromContext_HTTPAndWSPayloadShapes(t *testing.T) {
string(restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`))),
)
}
func TestAliasOpenAIOAuthReservedToolNames_SessionUpdateOnlyTouchesFunctionProtocolNodes(t *testing.T) {
body := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"},{"type":"image_generation","name":"python"},{"type":"namespace","name":"python","tools":[{"type":"function","name":"shell"}]}]},"metadata":{"name":"python"},"sequence":900719925474099312345}`)
aliased, reverse, changed, err := aliasOpenAIOAuthReservedToolNamesBody(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "python", reverse[codexPythonToolAlias])
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(aliased, "session.tools.0.name").String())
require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.1.name").String())
require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.2.name").String())
require.Equal(t, "shell", gjson.GetBytes(aliased, "session.tools.2.tools.0.name").String())
require.Equal(t, "python", gjson.GetBytes(aliased, "metadata.name").String())
require.Equal(t, "900719925474099312345", gjson.GetBytes(aliased, "sequence").Raw)
}
func TestRestoreCodexToolNamesInJSON_OnlyTouchesResponseToolCallNodesAndPreservesNumbers(t *testing.T) {
reverse := map[string]string{codexPythonToolAlias: "python"}
body := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"python__sub2api"},{"type":"message","name":"python__sub2api","content":[]}]},"metadata":{"name":"python__sub2api"},"sequence":900719925474099312345}`)
restored := restoreCodexToolNamesInJSON(body, reverse)
require.Equal(t, "python", gjson.GetBytes(restored, "response.output.0.name").String())
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "response.output.1.name").String())
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
require.Equal(t, "900719925474099312345", gjson.GetBytes(restored, "sequence").Raw)
}
func TestRestoreCodexToolNamesInJSON_ExplicitHTTPAndSSEToolCallProtocols(t *testing.T) {
reverse := map[string]string{codexPythonToolAlias: "python"}
tests := []struct {
name string
body string
path string
}{
{
name: "chat http",
body: `{"choices":[{"message":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
path: "choices.0.message.tool_calls.0.function.name",
},
{
name: "chat sse",
body: `{"choices":[{"delta":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
path: "choices.0.delta.tool_calls.0.function.name",
},
{
name: "messages tool use",
body: `{"type":"content_block_start","content":[{"type":"tool_use","name":"python__sub2api"}],"metadata":{"name":"python__sub2api"}}`,
path: "content.0.name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
restored := restoreCodexToolNamesInJSON([]byte(tt.body), reverse)
require.Equal(t, "python", gjson.GetBytes(restored, tt.path).String())
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
})
}
}
func TestAliasOpenAIOAuthReservedToolNames_PromptCompatibilityRunsFirst(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","prompt":[{"type":"function_call","name":"python","call_id":"fc_1"}],"functions":[{"name":"python"}],"function_call":{"name":"python"},"sequence":900719925474099312345}`)
reqBody, err := getOpenAIRequestBodyMap(nil, body)
require.NoError(t, err)
result := applyCodexOAuthTransform(reqBody, true, false)
require.NoError(t, result.Error)
require.Equal(t, codexPythonToolAlias, reqBody["input"].([]any)[0].(map[string]any)["name"])
require.Equal(t, codexPythonToolAlias, reqBody["tools"].([]any)[0].(map[string]any)["name"])
require.Equal(t, codexPythonToolAlias, reqBody["tool_choice"].(map[string]any)["name"])
encoded, err := json.Marshal(reqBody)
require.NoError(t, err)
require.Equal(t, "900719925474099312345", gjson.GetBytes(encoded, "sequence").Raw)
}
func TestCodexToolNameReverse_WSSessionReplacementDoesNotChangeActiveTurn(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
setCodexToolNameReverse(c, nil)
first := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
updateCodexToolNameReverseForWSFrame(c, first, map[string]string{codexPythonToolAlias: "python"})
update := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`)
updateCodexToolNameReverseForWSFrame(c, update, nil)
currentOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
require.Equal(t, "python", gjson.GetBytes(currentOutput, "item.name").String())
sessionEcho := restoreCodexToolNamesFromContext(c, []byte(`{"type":"session.updated","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`))
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(sessionEcho, "session.tools.0.name").String())
next := []byte(`{"type":"response.create","input":"next"}`)
updateCodexToolNameReverseForWSFrame(c, next, nil)
nextOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(nextOutput, "item.name").String())
sessionPython := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"}]}}`)
updateCodexToolNameReverseForWSFrame(c, sessionPython, map[string]string{codexPythonToolAlias: "python"})
explicitLiteral := []byte(`{"type":"response.create","input":[{"type":"additional_tools","tools":[{"type":"function","name":"python__sub2api"}]}]}`)
updateCodexToolNameReverseForWSFrame(c, explicitLiteral, nil)
literalOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(literalOutput, "item.name").String())
updateCodexToolNameReverseForWSFrame(c, next, nil)
inheritedOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
require.Equal(t, "python", gjson.GetBytes(inheritedOutput, "item.name").String())
}
func TestDecodeOpenAIJSONUseNumberRejectsTrailingDocument(t *testing.T) {
var decoded map[string]any
require.Error(t, decodeOpenAIJSONUseNumber([]byte(`{"name":"python"}{"extra":true}`), &decoded))
}
func TestRestoreCodexToolNamesFromSSEContextUsesEventLineTypeWithoutAddingType(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: codexReservedPythonToolName})
payload := []byte(`{"item":{"type":"function_call","name":"python__sub2api"},"metadata":{"name":"python__sub2api"}}`)
restored := restoreCodexToolNamesFromSSEContext(c, payload, "response.output_item.done")
require.Equal(t, codexReservedPythonToolName, gjson.GetBytes(restored, "item.name").String())
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
require.False(t, gjson.GetBytes(restored, "type").Exists())
}
@@ -173,15 +173,6 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult {
result := codexTransformResult{}
toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
if err != nil {
result.Error = err
return result
}
result.ToolNameReverse = toolNameReverse
if toolNamesChanged {
result.Modified = true
}
if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) {
result.Modified = true
}
@@ -273,6 +264,18 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth
if normalizeCodexTools(reqBody) {
result.Modified = true
}
// Collect aliases only after prompt/functions/function_call compatibility
// has produced the final Responses protocol nodes. Otherwise references
// introduced by those migrations can retain the reserved name.
toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
if err != nil {
result.Error = err
return result
}
result.ToolNameReverse = toolNameReverse
if toolNamesChanged {
result.Modified = true
}
if normalizeCodexToolChoice(reqBody) {
result.Modified = true
}
@@ -1218,7 +1221,7 @@ func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool {
}
func normalizeOpenAIModelForUpstream(account *Account, model string) string {
if account == nil || account.Type == AccountTypeOAuth {
if account == nil || account.UsesOpenAICodexProtocol() {
return normalizeCodexModel(model)
}
return strings.TrimSpace(model)
@@ -1599,7 +1602,7 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
if typ == "reasoning" {
newItem := make(map[string]any, len(m))
for key, value := range m {
if key == "id" {
if key == "id" || key == "call_id" {
// rs_* id replayed under store=false 404s; strip it.
continue
}
@@ -1875,6 +1875,7 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
map[string]any{
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
"call_id": "remove_non_pair_call_id",
"encrypted_content": "gAAAAAB-enc-payload",
"summary": []any{},
},
@@ -1897,6 +1898,8 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
// PreserveReferences=true (id lookup, not the item, triggers the 404).
_, hasID := item["id"]
require.False(t, hasID)
_, hasCallID := item["call_id"]
require.False(t, hasCallID)
// summary passed through untouched.
summary, ok := item["summary"].([]any)
require.True(t, ok)
@@ -1,7 +1,6 @@
package service
import (
"encoding/json"
"net/http"
"strings"
@@ -30,7 +29,7 @@ func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) {
return body, false, nil
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil {
return body, false, err
}
input, ok := payload["input"].([]any)
@@ -57,7 +56,7 @@ func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) {
}
normalized = append(normalized, map[string]any{"type": "compaction_trigger"})
payload["input"] = normalized
encoded, err := json.Marshal(payload)
encoded, err := marshalOpenAIUpstreamJSON(payload)
if err != nil {
return body, false, err
}
@@ -143,7 +142,7 @@ func applyOpenAICodexBetaFeatures(c *gin.Context, account *Account, h http.Heade
ensureOpenAIRemoteCompactionV2BetaFeature(h)
return
}
if account == nil || !account.IsOpenAIOAuth() {
if account == nil || !account.IsOpenAIOAuthLike() {
return
}
if hasOpenAICodexBetaFeaturesHeader(h) {
@@ -74,3 +74,35 @@ func TestNormalizeCompactionTriggerInputOrder_AlreadyFinalPreservesBytes(t *test
require.False(t, changed)
require.Equal(t, string(body), string(normalized))
}
func TestNormalizeCompactionTriggerInputOrder_PreservesHistoryAndLargeNumbers(t *testing.T) {
body := []byte(`{"input":[{"type":"compaction_trigger"},{"type":"message","id":"msg_1","content":"visible"},{"type":"function_call_output","call_id":"call_1","output":"result","sequence":9007199254740993}]}`)
normalized, changed, err := NormalizeCompactionTriggerInputOrder(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.1.type").String())
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.1.sequence").Raw)
require.Equal(t, "compaction_trigger", gjson.GetBytes(normalized, "input.2.type").String())
second, changedAgain, err := NormalizeCompactionTriggerInputOrder(normalized)
require.NoError(t, err)
require.False(t, changedAgain)
require.Equal(t, normalized, second)
}
func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *testing.T) {
body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
require.NoError(t, err)
require.True(t, changed)
items := gjson.GetBytes(normalized, "input").Array()
require.Len(t, items, 4)
require.Equal(t, "function_call", items[0].Get("type").String())
require.Equal(t, "function_call_output", items[1].Get("type").String())
require.Equal(t, "message", items[2].Get("type").String())
require.Equal(t, "compaction_trigger", items[3].Get("type").String())
}
@@ -0,0 +1,145 @@
package service
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
type openAICompactFallbackSignal struct {
payload []byte
message string
}
func (e *openAICompactFallbackSignal) Error() string {
if e == nil || strings.TrimSpace(e.message) == "" {
return "upstream compact request failed"
}
return e.message
}
func asOpenAICompactFallbackSignal(err error) (*openAICompactFallbackSignal, bool) {
var signal *openAICompactFallbackSignal
return signal, errors.As(err, &signal) && signal != nil
}
func isExplicitOpenAICompactContext(c *gin.Context) bool {
return isOpenAIResponsesCompactPath(c) || isOpenAINativeCompactionV2(c)
}
func newOpenAICompactFallbackSignal(c *gin.Context, payload []byte, message string) error {
if !isExplicitOpenAICompactContext(c) ||
!isOpenAICompactModelFailure(http.StatusBadRequest, message, payload) {
return nil
}
return &openAICompactFallbackSignal{
payload: append([]byte(nil), payload...),
message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)),
}
}
func isExplicitOpenAICompactRequest(c *gin.Context, body []byte) bool {
return isOpenAIResponsesCompactPath(c) || HasCompactionTriggerInInput(body)
}
// resolveOpenAICompactFallbackModel prefers the account's compact-only rule
// for the client-visible model. The process-wide fallback is used only when
// that account has no matching compact rule.
func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Account, requestedModel string) string {
requestedModel = strings.TrimSpace(requestedModel)
if account != nil {
if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched {
if mapped = strings.TrimSpace(mapped); mapped != "" {
return mapped
}
}
}
if s == nil || s.cfg == nil {
return ""
}
fallback := strings.TrimSpace(s.cfg.Gateway.OpenAICompactModel)
if fallback == "" {
return ""
}
return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false))
}
func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
return true
}
if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound {
return false
}
values := []string{
extractUpstreamErrorCode(upstreamBody),
upstreamMsg,
gjson.GetBytes(upstreamBody, "error.type").String(),
gjson.GetBytes(upstreamBody, "response.error.code").String(),
gjson.GetBytes(upstreamBody, "response.error.type").String(),
}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "model_not_found", "model_not_available", "unsupported_model", "invalid_model":
return true
}
if strings.Contains(value, "model") && (strings.Contains(value, "not found") ||
strings.Contains(value, "does not exist") ||
strings.Contains(value, "unavailable") ||
strings.Contains(value, "unsupported") ||
strings.Contains(value, "not supported")) {
return true
}
}
// Some compact providers return only a failed response shell. It is safe to
// retry that shape for an explicit compact request, but a populated error is
// left untouched so business and policy failures keep their original wire.
if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "response.status").String()), "failed") ||
strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "status").String()), "failed") {
for _, path := range []string{
"error.message", "error.code", "error.type",
"response.error.message", "response.error.code", "response.error.type",
} {
if strings.TrimSpace(gjson.GetBytes(upstreamBody, path).String()) != "" {
return false
}
}
return strings.TrimSpace(upstreamMsg) == ""
}
return false
}
// prepareOpenAICompactFallbackRetry returns a body for one safe, same-account
// retry. Callers invoke it only before any downstream response has been
// written; it changes the model and deliberately leaves path, trigger, and
// native-v2 context state untouched.
func (s *OpenAIGatewayService) prepareOpenAICompactFallbackRetry(
c *gin.Context,
account *Account,
requestedModel string,
currentBody []byte,
statusCode int,
upstreamMsg string,
upstreamBody []byte,
alreadyRetried bool,
) ([]byte, string, bool) {
if alreadyRetried || !isExplicitOpenAICompactRequest(c, currentBody) ||
!isOpenAICompactModelFailure(statusCode, upstreamMsg, upstreamBody) {
return currentBody, "", false
}
fallbackModel := s.resolveOpenAICompactFallbackModel(account, requestedModel)
currentModel := strings.TrimSpace(gjson.GetBytes(currentBody, "model").String())
if fallbackModel == "" || strings.EqualFold(fallbackModel, currentModel) {
return currentBody, "", false
}
retryBody := ReplaceModelInBody(currentBody, fallbackModel)
if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(retryBody, "model").String()), currentModel) {
return currentBody, "", false
}
return retryBody, fallbackModel, true
}
@@ -0,0 +1,274 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func newOpenAICompactFallbackTestContext(t *testing.T, path string) *gin.Context {
t.Helper()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
return c
}
func TestPrepareOpenAICompactFallbackRetryRequiresExplicitCompact(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
body := []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":"hello"}]}`)
errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"maximum context length exceeded"}}`)
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", body, http.StatusBadRequest, "maximum context length exceeded", errorBody, false,
)
require.False(t, retry)
require.Empty(t, fallbackModel)
require.Equal(t, body, retryBody)
}
func TestPrepareOpenAICompactFallbackRetryPreservesNativeTriggerAndContext(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
MarkOpenAINativeCompactionV2(c)
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)
pathBefore := openAIResponsesRequestPathSuffix(c)
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", body, http.StatusBadRequest, "context window exceeded", errorBody, false,
)
require.True(t, retry)
require.Equal(t, "gpt-5.4", fallbackModel)
require.Equal(t, "gpt-5.4", gjson.GetBytes(retryBody, "model").String())
require.True(t, HasCompactionTriggerInInput(retryBody))
require.True(t, isOpenAINativeCompactionV2(c))
require.Equal(t, pathBefore, openAIResponsesRequestPathSuffix(c))
}
func TestResolveOpenAICompactFallbackModelPrefersAccountMapping(t *testing.T) {
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}}
account := &Account{Credentials: map[string]any{
"compact_model_mapping": map[string]any{"gpt-5.5": "account-compact"},
}}
require.Equal(t, "account-compact", svc.resolveOpenAICompactFallbackModel(account, "gpt-5.5"))
require.Equal(t, "global-compact", svc.resolveOpenAICompactFallbackModel(account, "unmapped-model"))
}
func TestOpenAIGatewayForwardUsesGlobalCompactModelOnInitialLegacyRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[]}`)
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"global-compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}},
httpUpstream: upstream,
}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1)
require.Equal(t, "global-compact", gjson.GetBytes(upstream.bodies[0], "model").String())
require.Contains(t, upstream.requests[0].URL.Path, "/compact")
}
func TestPrepareOpenAICompactFallbackRetryLegacyPathAndSingleAttemptGuard(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
body := []byte(`{"model":"gpt-5.5","input":[]}`)
errorBody := []byte(`{"response":{"status":"failed","error":null}}`)
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", body, http.StatusBadRequest, "", errorBody, false,
)
require.True(t, retry)
require.Equal(t, "gpt-5.4", fallbackModel)
require.Equal(t, "/compact", openAIResponsesRequestPathSuffix(c))
secondBody, secondModel, secondRetry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", retryBody, http.StatusBadRequest, "", errorBody, true,
)
require.False(t, secondRetry)
require.Empty(t, secondModel)
require.Equal(t, retryBody, secondBody)
}
func TestPrepareOpenAICompactFallbackRetryDoesNotHideSpecificBusinessFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
body := []byte(`{"model":"gpt-5.5","input":[]}`)
errorBody := []byte(`{"response":{"status":"failed","error":{"type":"permission_error","message":"workspace denied"}}}`)
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", body, http.StatusBadRequest, "workspace denied", errorBody, false,
)
require.False(t, retry)
require.Empty(t, fallbackModel)
require.Equal(t, body, retryBody)
}
func TestPrepareOpenAICompactFallbackRetrySkipsSameModel(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}}
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
body := []byte(`{"model":"gpt-5.5","input":[]}`)
errorBody := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
_, _, retry := svc.prepareOpenAICompactFallbackRetry(
c, nil, "gpt-5.5", body, http.StatusNotFound, "model not found", errorBody, false,
)
require.False(t, retry)
}
func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
MarkOpenAINativeCompactionV2(c)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
},
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
require.True(t, HasCompactionTriggerInInput(upstream.bodies[1]))
require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
}
func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
MarkOpenAINativeCompactionV2(c)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
},
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
}
func TestOpenAIGatewayForwardRetriesStreamingCompactFailureBeforeOutput(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
MarkOpenAINativeCompactionV2(c)
failed := "event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
completed := "event: response.completed\n" +
`data: {"type":"response.completed","response":{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n"
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed))},
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
require.NotContains(t, recorder.Body.String(), "context_length_exceeded")
require.Contains(t, recorder.Body.String(), "response.completed")
}
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -39,6 +40,7 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-acc",
"model_mapping": map[string]any{"gpt-5.4": "gpt-5.3-codex"},
"compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
},
Status: StatusActive,
@@ -51,6 +53,80 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr
require.Equal(t, "gpt-5.4", result.Model)
require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel)
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
opsModel, exists := c.Get(OpsUpstreamModelKey)
require.True(t, exists)
require.Equal(t, "gpt-5.4-openai-compact", opsModel)
}
func TestOpenAIGatewayService_Forward_APIKeyCompactSanitizesStatelessReplayAfterStoreWasDropped(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gpt-5.5","parallel_tool_calls":true,"input":[` +
`{"type":"reasoning","id":"rs_server_only","summary":[]},` +
`{"type":"item_reference","id":"rs_server_only"},` +
`{"type":"message","role":"user","content":"compact this"}` +
`]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"resp_compact","status":"completed","output":[{"type":"compaction","encrypted_content":"cipher"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
)),
}}
svc := &OpenAIGatewayService{
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
}},
}
account := &Account{
ID: 7, Name: "azure-openai", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "test-key"}, Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
require.Equal(t, "message", gjson.GetBytes(upstream.lastBody, "input.0.type").String())
}
func TestOpenAIGatewayService_Forward_NormalizesCompactionTriggerAfterHistoryCleanup(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gpt-5.4","stream":false,"input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_trigger","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
}}
svc := &OpenAIGatewayService{httpUpstream: upstream}
account := &Account{
ID: 4, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Status: StatusActive, Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
items := gjson.GetBytes(upstream.lastBody, "input").Array()
require.Len(t, items, 4)
require.Equal(t, "function_call", items[0].Get("type").String())
require.Equal(t, "function_call_output", items[1].Get("type").String())
require.Equal(t, "message", items[2].Get("type").String())
require.Equal(t, "compaction_trigger", items[3].Get("type").String())
}
func TestOpenAIGatewayService_Forward_NonCompactRequestIgnoresCompactOnlyModelMapping(t *testing.T) {
@@ -132,4 +208,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesU
require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel)
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "gpt-5.4", gjson.GetBytes(rec.Body.Bytes(), "model").String())
opsModel, exists := c.Get(OpsUpstreamModelKey)
require.True(t, exists)
require.Equal(t, "gpt-5.4-openai-compact", opsModel)
}
@@ -318,7 +318,7 @@ func TestHandlePassthroughSSEToJSON_CompactRawOutputItemDoneRepairsEmptyTerminal
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "")
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
require.NoError(t, err)
require.NotNil(t, result)
@@ -512,7 +512,7 @@ func TestHandleNonStreamingResponsePassthrough_CompactClientStreamBridgesToSSE(t
}`)),
}
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "")
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
require.NoError(t, err)
require.NotNil(t, result)
@@ -4,6 +4,8 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -14,16 +16,50 @@ import (
// repository 层 gatewayCache 附带实现(类型断言探测接入,不改 GatewayCache
// 共享接口);测试 stub 不实现时屏蔽能力自动降级关闭。
type CyberSessionBlockStore interface {
SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error
IsCyberSessionBlocked(ctx context.Context, key string) (bool, error)
SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error
IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error)
FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error)
}
// CyberSessionBlockKey 派生会话屏蔽 key:仅用显式会话标识(header
// session_id/conversation_id 或 body prompt_cache_key),混入 apiKeyID 隔离后
// sha256。无显式标识返回空串——调用方必须放行(粒度决策:不退化到
// user/apikey/内容派生)。
func CyberSessionBlockKey(apiKeyID int64, c *gin.Context, body []byte) string {
raw := explicitOpenAISessionID(c, body)
// CyberSessionExplicitBlockKey returns an inexpensive exact key when the
// client supplies a stable session signal.
func CyberSessionExplicitBlockKey(apiKeyID int64, c *gin.Context, body []byte) string {
return hashCyberSessionBlockKey(apiKeyID, explicitOpenAISessionID(c, body))
}
// CyberSessionTranscriptBlockKeys returns the exact full-request key followed
// by an optional rewrite-tolerant context key. The latter is emitted only after
// model-generated history has been observed.
func CyberSessionTranscriptBlockKeys(apiKeyID int64, body []byte) []string {
derived := deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body)
if len(derived.lookupKeys) == 0 {
return nil
}
keys := []string{derived.lookupKeys[len(derived.lookupKeys)-1]}
if derived.preLatestUserKey != "" && derived.preLatestUserKey != keys[0] {
keys = append(keys, derived.preLatestUserKey)
}
return keys
}
func CyberSessionTranscriptLookupKeys(apiKeyID int64, body []byte) []string {
return deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body).lookupKeys
}
// CyberSessionScopeKey is a coarse, non-blocking fingerprint used only to
// avoid transcript parsing and MGET for sources that never produced a hit.
func CyberSessionScopeKey(apiKeyID int64, clientIP, userAgent string) string {
if apiKeyID <= 0 {
return ""
}
raw := "cyber-scope:v1|api_key=" + strconv.FormatInt(apiKeyID, 10) +
"|ip=" + strings.TrimSpace(clientIP) +
"|ua=" + NormalizeSessionUserAgent(userAgent)
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func hashCyberSessionBlockKey(apiKeyID int64, raw string) string {
if raw == "" {
return ""
}
@@ -59,8 +95,8 @@ func (s *OpenAIGatewayService) CyberSessionBlockRuntime(ctx context.Context) (bo
// MarkCyberSessionBlocked 把会话写入屏蔽表(写入点:cyber 命中后)。
// 开关关闭、key 为空或存储不可用时静默跳过。
func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key string) {
if key == "" {
func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string) {
if s == nil || len(keys) == 0 {
return
}
enabled, ttl := s.CyberSessionBlockRuntime(ctx)
@@ -71,29 +107,49 @@ func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key
if store == nil {
return
}
if err := store.SetCyberSessionBlocked(ctx, key, ttl); err != nil {
if err := store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl); err != nil {
logger.LegacyPrintf("service.openai_gateway", "cyber session block write failed: err=%v", err)
}
}
// IsCyberSessionBlocked 查询会话是否被屏蔽(拦截点)。开关关闭、key 为空、
// 存储不可用或查询出错时返回 false(fail-open:屏蔽是增强防护,不阻断主链路)。
func (s *OpenAIGatewayService) IsCyberSessionBlocked(ctx context.Context, key string) bool {
if key == "" {
return false
}
// FindCyberSessionBlockedForRequest applies explicit-first lookup followed by
// scope-gated transcript matching. All failures remain fail-open.
func (s *OpenAIGatewayService) FindCyberSessionBlockedForRequest(ctx context.Context, apiKeyID int64, c *gin.Context, body []byte, clientIP, userAgent string) string {
enabled, _ := s.CyberSessionBlockRuntime(ctx)
if !enabled {
return false
return ""
}
store := s.cyberSessionBlockStore()
if store == nil {
return false
return ""
}
blocked, err := store.IsCyberSessionBlocked(ctx, key)
if explicitKey := CyberSessionExplicitBlockKey(apiKeyID, c, body); explicitKey != "" {
key, err := store.FindCyberSessionBlocked(ctx, []string{explicitKey})
if err != nil {
logger.LegacyPrintf("service.openai_gateway", "cyber explicit session read failed: err=%v", err)
return ""
}
if key != "" {
return key
}
}
scopeKey := CyberSessionScopeKey(apiKeyID, clientIP, userAgent)
active, err := store.IsCyberSessionScopeActive(ctx, scopeKey)
if err != nil {
logger.LegacyPrintf("service.openai_gateway", "cyber session block read failed: err=%v", err)
return false
logger.LegacyPrintf("service.openai_gateway", "cyber session scope read failed: err=%v", err)
return ""
}
return blocked
if !active {
return ""
}
keys := CyberSessionTranscriptLookupKeys(apiKeyID, body)
if len(keys) == 0 {
return ""
}
key, err := store.FindCyberSessionBlocked(ctx, keys)
if err != nil {
logger.LegacyPrintf("service.openai_gateway", "cyber session block batch read failed: err=%v", err)
return ""
}
return key
}
@@ -23,57 +23,94 @@ func newCyberBlockTestCtx(headers map[string]string, body string) (*gin.Context,
return c, []byte(body)
}
// TestCyberSessionBlockKey verifies F5a key derivation: explicit session signals
// only (header session_id/conversation_id or body prompt_cache_key), apiKey
// isolated, and EMPTY when no explicit signal (no content-derived fallback —
// "不退化" decision).
func TestCyberSessionBlockKey(t *testing.T) {
func TestCyberSessionExplicitBlockKey(t *testing.T) {
c1, b1 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
k1 := CyberSessionBlockKey(101, c1, b1)
k1 := CyberSessionExplicitBlockKey(101, c1, b1)
require.NotEmpty(t, k1)
// Same session, different apiKey → different key (isolation).
c2, b2 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
require.NotEqual(t, k1, CyberSessionBlockKey(202, c2, b2))
require.NotEqual(t, k1, CyberSessionExplicitBlockKey(202, c2, b2))
// Same session + same apiKey → stable key.
c3, b3 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
require.Equal(t, k1, CyberSessionBlockKey(101, c3, b3))
require.Equal(t, k1, CyberSessionExplicitBlockKey(101, c3, b3))
// prompt_cache_key in body counts as explicit.
c4, b4 := newCyberBlockTestCtx(nil, `{"prompt_cache_key":"pck-1"}`)
require.NotEmpty(t, CyberSessionBlockKey(101, c4, b4))
require.NotEmpty(t, CyberSessionExplicitBlockKey(101, c4, b4))
// No explicit signal → empty key → caller must skip blocking entirely.
c5, b5 := newCyberBlockTestCtx(nil, `{"input":"hello world"}`)
require.Empty(t, CyberSessionBlockKey(101, c5, b5))
require.Empty(t, CyberSessionExplicitBlockKey(101, c5, b5))
// conversation_id header counts as explicit; key is stable and non-empty.
c6, b6 := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
k6 := CyberSessionBlockKey(101, c6, b6)
k6 := CyberSessionExplicitBlockKey(101, c6, b6)
require.NotEmpty(t, k6)
c6b, b6b := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
require.Equal(t, k6, CyberSessionBlockKey(101, c6b, b6b), "conversation_id key must be stable")
require.Equal(t, k6, CyberSessionExplicitBlockKey(101, c6b, b6b), "conversation_id key must be stable")
}
func TestCyberTranscriptBlockKeysRequireModelGeneratedHistory(t *testing.T) {
first := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question one"}]}`)
second := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question two"}]}`)
firstKeys := CyberSessionTranscriptBlockKeys(77, first)
secondKeys := CyberSessionTranscriptBlockKeys(77, second)
require.Len(t, firstKeys, 1)
require.Len(t, secondKeys, 1)
require.NotEqual(t, firstKeys[0], secondKeys[0])
hit := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
continuation := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
hitKeys := CyberSessionTranscriptBlockKeys(77, hit)
require.Len(t, hitKeys, 2)
require.Contains(t, CyberSessionTranscriptLookupKeys(77, continuation), hitKeys[1])
}
func TestCyberTranscriptBlockKeysWebSocketResponseCreate(t *testing.T) {
body := []byte(`{"type":"response.create","response":{"prompt_cache_key":"ws-session","input":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}}`)
c, _ := newCyberBlockTestCtx(nil, string(body))
require.NotEmpty(t, CyberSessionExplicitBlockKey(88, c, body))
require.Len(t, CyberSessionTranscriptBlockKeys(88, body), 2)
}
// --- fakes ---
type fakeCyberBlockStore struct {
blocked map[string]bool
scopes map[string]bool
}
var _ CyberSessionBlockStore = (*fakeCyberBlockStore)(nil)
func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, key string, _ time.Duration) error {
func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, scopeKey string, keys []string, _ time.Duration) error {
if f.blocked == nil {
f.blocked = map[string]bool{}
}
f.blocked[key] = true
for _, key := range keys {
f.blocked[key] = true
}
if scopeKey != "" {
if f.scopes == nil {
f.scopes = map[string]bool{}
}
f.scopes[scopeKey] = true
}
return nil
}
func (f *fakeCyberBlockStore) IsCyberSessionBlocked(_ context.Context, key string) (bool, error) {
return f.blocked[key], nil
func (f *fakeCyberBlockStore) IsCyberSessionScopeActive(_ context.Context, scopeKey string) (bool, error) {
return f.scopes[scopeKey], nil
}
func (f *fakeCyberBlockStore) FindCyberSessionBlocked(_ context.Context, keys []string) (string, error) {
for _, key := range keys {
if f.blocked[key] {
return key, nil
}
}
return "", nil
}
// fakeSettingRepo is a minimal SettingRepository stub for unit tests.
@@ -155,25 +192,27 @@ func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (s
return "", ErrReasoningContentNotFound
}
func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
return c.store.SetCyberSessionBlocked(ctx, key, ttl)
func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
return c.store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl)
}
func (c *comboCacheAndStore) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) {
return c.store.IsCyberSessionBlocked(ctx, key)
func (c *comboCacheAndStore) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
return c.store.IsCyberSessionScopeActive(ctx, scopeKey)
}
func (c *comboCacheAndStore) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
return c.store.FindCyberSessionBlocked(ctx, keys)
}
// --- tests ---
// TestIsCyberSessionBlocked_EmptyKeyAndNilService covers the fail-open paths:
// empty key, nil service, store missing → always false / no panic.
func TestIsCyberSessionBlocked_EmptyKeyAndNilService(t *testing.T) {
func TestFindCyberSessionBlocked_EmptyAndNilService(t *testing.T) {
var nilSvc *OpenAIGatewayService
require.False(t, nilSvc.IsCyberSessionBlocked(context.Background(), "k"))
require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "k") })
require.Empty(t, nilSvc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "", []string{"k"}) })
svc := &OpenAIGatewayService{}
require.False(t, svc.IsCyberSessionBlocked(context.Background(), ""))
require.False(t, svc.IsCyberSessionBlocked(context.Background(), "k"), "no store + no settings → fail-open false")
require.Empty(t, svc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
}
// TestCyberSessionBlock_RoundTrip exercises the type-assertion success path:
@@ -200,15 +239,42 @@ func TestCyberSessionBlock_RoundTrip(t *testing.T) {
ctx := context.Background()
const testKey = "deadbeef1234"
// Before marking: not blocked.
require.False(t, svc.IsCyberSessionBlocked(ctx, testKey))
c, body := newCyberBlockTestCtx(map[string]string{"session_id": "sess-roundtrip"}, `{}`)
explicitKey := CyberSessionExplicitBlockKey(1, c, body)
require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
// Mark as blocked.
svc.MarkCyberSessionBlocked(ctx, testKey)
svc.MarkCyberSessionBlocked(ctx, "", []string{explicitKey, testKey})
// After marking: blocked.
require.True(t, svc.IsCyberSessionBlocked(ctx, testKey))
require.Equal(t, explicitKey, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
}
// Different key: still not blocked.
require.False(t, svc.IsCyberSessionBlocked(ctx, "other-key"))
func TestFindCyberSessionBlockedForRequestUsesScopeForTranscript(t *testing.T) {
settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{
SettingKeyCyberSessionBlockEnabled: "true",
SettingKeyCyberSessionBlockTTLSeconds: "60",
}}}
combo := &comboCacheAndStore{}
svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc}
ctx := context.Background()
hitBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
nextBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
nextCtx, _ := newCyberBlockTestCtx(nil, string(nextBody))
const clientIP = "203.0.113.20"
const userAgent = "Codex CLI 1.2.3"
blockKey := CyberSessionTranscriptBlockKeys(9, hitBody)[1]
// Without an active source scope, transcript candidates are never blocks.
require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, userAgent))
scopeKey := CyberSessionScopeKey(9, clientIP, userAgent)
svc.MarkCyberSessionBlocked(ctx, scopeKey, []string{blockKey})
require.Equal(t, blockKey, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, "Codex CLI 1.2.4"))
}
func TestCyberSessionScopeKeyNormalizesUserAgentVersion(t *testing.T) {
base := CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.3")
require.NotEmpty(t, base)
require.Equal(t, base, CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.4"))
require.NotEqual(t, base, CyberSessionScopeKey(8, "203.0.113.10", "Codex CLI 1.2.3"))
require.NotEqual(t, base, CyberSessionScopeKey(7, "203.0.113.11", "Codex CLI 1.2.3"))
}
@@ -0,0 +1,136 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strconv"
"strings"
"github.com/tidwall/gjson"
)
type openAICyberTranscriptBlockKeys struct {
lookupKeys []string
preLatestUserKey string
}
// deriveOpenAICyberTranscriptBlockKeys returns cumulative semantic-history
// hashes plus the context key immediately before the latest user turn. The
// context key requires model-generated history so shared first-turn templates
// cannot block unrelated conversations.
func deriveOpenAICyberTranscriptBlockKeys(apiKeyID int64, body []byte) openAICyberTranscriptBlockKeys {
if len(body) == 0 {
return openAICyberTranscriptBlockKeys{}
}
root := openAIRequestPayloadView(body)
if !root.Exists() || !root.IsObject() {
return openAICyberTranscriptBlockKeys{}
}
h := sha256.New()
_, _ = h.Write([]byte("cyber-transcript:v3|api_key="))
_, _ = h.Write([]byte(strconv.FormatInt(apiKeyID, 10)))
// Model and tool definitions are request configuration rather than history.
// Root instructions remain model-visible context and participate in identity.
for _, field := range []string{"instructions"} {
v := root.Get(field)
if !v.Exists() || (v.Type == gjson.String && strings.TrimSpace(v.String()) == "") {
continue
}
canonical := v.Raw
if v.Type == gjson.String {
canonical = v.String()
} else {
canonical = normalizeCompatSeedJSON(json.RawMessage(v.Raw))
}
_, _ = h.Write([]byte("|"))
_, _ = h.Write([]byte(field))
_, _ = h.Write([]byte("="))
_, _ = h.Write([]byte(canonical))
}
appendSequence := func(sequence gjson.Result) openAICyberTranscriptBlockKeys {
if !sequence.Exists() || !sequence.IsArray() {
return openAICyberTranscriptBlockKeys{}
}
result := openAICyberTranscriptBlockKeys{
lookupKeys: make([]string, 0, int(sequence.Get("#").Int())),
}
// This is an entropy heuristic, not provenance proof: authenticated
// server-side history would be required to distinguish fixed few-shot
// assistant items perfectly.
hasModelGeneratedItem := false
sequence.ForEach(func(_, item gjson.Result) bool {
canonical := item.Raw
if item.Type == gjson.String {
encoded, _ := json.Marshal(item.String())
canonical = string(encoded)
} else if item.Type == gjson.JSON {
canonical = normalizeCompatSeedJSON(json.RawMessage(item.Raw))
}
if strings.TrimSpace(canonical) == "" {
return true
}
if openAICyberTranscriptItemStartsUserTurn(item) && hasModelGeneratedItem && len(result.lookupKeys) > 0 {
result.preLatestUserKey = result.lookupKeys[len(result.lookupKeys)-1]
}
_, _ = h.Write([]byte("|item="))
_, _ = h.Write([]byte(canonical))
result.lookupKeys = append(result.lookupKeys, hex.EncodeToString(h.Sum(nil)))
if openAICyberTranscriptItemIsModelGenerated(item) {
hasModelGeneratedItem = true
}
return true
})
return result
}
if messages := root.Get("messages"); messages.Exists() {
return appendSequence(messages)
}
input := root.Get("input")
if input.IsArray() {
return appendSequence(input)
}
if input.Type == gjson.String && strings.TrimSpace(input.String()) != "" {
encoded, _ := json.Marshal(input.String())
_, _ = h.Write([]byte("|item="))
_, _ = h.Write(encoded)
return openAICyberTranscriptBlockKeys{lookupKeys: []string{hex.EncodeToString(h.Sum(nil))}}
}
return openAICyberTranscriptBlockKeys{}
}
func openAICyberTranscriptItemStartsUserTurn(item gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(item.Get("role").String()), "user") {
content := item.Get("content")
if content.IsArray() {
hasUserContent := false
content.ForEach(func(_, block gjson.Result) bool {
switch strings.ToLower(strings.TrimSpace(block.Get("type").String())) {
case "tool_result", "function_call_output", "custom_tool_call_output", "computer_call_output":
default:
hasUserContent = true
}
return !hasUserContent
})
return hasUserContent
}
return content.Exists()
}
return strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "input_text")
}
func openAICyberTranscriptItemIsModelGenerated(item gjson.Result) bool {
switch strings.ToLower(strings.TrimSpace(item.Get("role").String())) {
case "assistant", "model":
return true
}
switch strings.ToLower(strings.TrimSpace(item.Get("type").String())) {
case "output_text", "function_call", "tool_call", "custom_tool_call", "computer_call":
return true
default:
return false
}
}
@@ -34,6 +34,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
SetOpsUpstreamModel(c, upstreamModel)
upstreamBody := body
if upstreamModel != originalModel {
upstreamBody = ReplaceModelInBody(body, upstreamModel)
@@ -135,11 +136,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
Detail: upstreamDetail,
})
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
if account.IsOpenAIOAuth() && resp.StatusCode == http.StatusTooManyRequests {
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
}
if isOpenAIUpstreamAccessStateError(upstreamMsg, respBody) {
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount)
}
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
}
writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter)
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
@@ -104,3 +104,85 @@ func TestForwardEmbeddings_APIKeyPassthroughRecordsUsageAndBatchInput(t *testing
require.Equal(t, "float", gjson.GetBytes(upstream.lastBody, "encoding_format").String())
require.Equal(t, int64(256), gjson.GetBytes(upstream.lastBody, "dimensions").Int())
}
func TestForwardEmbeddings_AccessStateUsesTypedFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody))
upstreamBody := []byte(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_embeddings_access_state"},
},
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 43,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "sk-test",
},
}
result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "")
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
require.False(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, "req_embeddings_access_state", failoverErr.ResponseHeaders.Get("x-request-id"))
require.False(t, c.Writer.Written())
}
func TestForwardEmbeddings_NonAccessFailoverKeepsLegacyShape(t *testing.T) {
gin.SetMode(gin.TestMode)
reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 44,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "sk-test",
},
}
result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "")
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
require.Empty(t, failoverErr.Stage)
require.Empty(t, failoverErr.Scope)
require.Empty(t, failoverErr.Reason)
require.Zero(t, failoverErr.ClientStatusCode)
require.Empty(t, failoverErr.ClientMessage)
require.Nil(t, failoverErr.ResponseHeaders)
require.False(t, c.Writer.Written())
}
@@ -127,7 +127,8 @@ func TestOpenAIGatewayService_HandleFailoverSideEffects_DoesNotRereadResponseBod
svc.handleFailoverSideEffects(context.Background(), resp, account, []byte(`{"error":{"type":"rate_limit_error","message":"rate limited"}}`))
})
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
}
func TestGetOpenAIRequestBodyMap_IgnoresLegacyContextCache(t *testing.T) {
@@ -348,6 +348,23 @@ func TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel(t *
require.Equal(t, "response.create", gjson.GetBytes(payload, "type").String())
}
func TestOpenAIWSPassthroughPolicyModelDoesNotApplyAccountMapping(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"model_mapping": map[string]any{"public-model": "private-model"},
},
Extra: map[string]any{"openai_passthrough": true},
}
responseCreate := []byte(`{"type":"response.create","model":"public-model"}`)
require.Equal(t, "public-model", openAIWSPassthroughPolicyModelForFrame(account, responseCreate))
sessionUpdate := []byte(`{"type":"session.update","session":{"model":"public-model"}}`)
require.Equal(t, "public-model", openAIWSPassthroughPolicyModelFromSessionFrame(account, sessionUpdate))
}
// TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses pins the
// inverse: when the wrapper has NO capturedSessionModel fallback (model is
// empty per-frame and no fallback is wired up), the policy fails to match
@@ -44,7 +44,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}},
{"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
{"type":"web_search_call","id":"item_unconstrained"}
{"type":"web_search_call","id":"item_wrong_web"}
]
}`)
@@ -68,29 +68,31 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String())
require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String())
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String())
require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.9.id").String())
require.False(t, gjson.GetBytes(forwarded, "input.9.id").Exists())
}
func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := newOpenAIImageGenerationControlTestService(upstream)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
account := newOpenAIImageGenerationControlTestAccount()
account.Type = AccountTypeOAuth
account.Credentials = map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
}
account.Extra = map[string]any{"openai_passthrough": true}
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
t.Run(accountType, func(t *testing.T) {
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := newOpenAIImageGenerationControlTestService(upstream)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
account := newOpenAIImageGenerationControlTestAccount()
account.Type = accountType
account.Credentials = map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
}
account.Extra = map[string]any{"openai_passthrough": true}
body := []byte(`{
body := []byte(`{
"model":"gpt-5.6-sol",
"stream":true,
"instructions":"test",
@@ -102,14 +104,58 @@ func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *tes
]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
})
}
}
func TestOpenAIGatewayService_SetupTokenLegacy_SanitizesAndTransforms(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := newOpenAIImageGenerationControlTestService(upstream)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
account := newOpenAIImageGenerationControlTestAccount()
account.Type = AccountTypeSetupToken
account.Credentials = map[string]any{
"access_token": "setup-token",
"chatgpt_account_id": "chatgpt-account",
}
account.Extra = map[string]any{"openai_passthrough": false}
body := []byte(`{
"model":"gpt-5.6-sol",
"stream":true,
"store":true,
"reasoning":{"mode":"pro"},
"instructions":"test",
"input":[
{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
{"type":"function_call_output","call_id":"call_orphan","output":"orphan"}
]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning.mode").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 1)
}
// TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs
@@ -164,7 +210,7 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
}{
{"reasoning item_* id", "reasoning", "item_bad_reasoning", true},
{"reasoning rs id", "reasoning", "rs_abc123", false},
{"reasoning empty id", "reasoning", "", false},
{"reasoning empty id", "reasoning", "", true},
{"message msg id", "message", "msg_abc", false},
{"message item id", "message", "item_x", true},
{"function_call fc id", "function_call", "fc_abc", false},
@@ -174,7 +220,10 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
{"custom tool fc id", "custom_tool_call", "fc_abc", true},
{"tool search tsc id", "tool_search_call", "tsc_abc", false},
{"tool search fc id", "tool_search_call", "fc_abc", true},
{"unconstrained type", "web_search_call", "ws_001", false},
{"web search ws id", "web_search_call", "ws_001", false},
{"web search item id", "web_search_call", "item_001", true},
{"custom output fc id", "custom_tool_call_output", "fc_001", false},
{"custom output ctco id", "custom_tool_call_output", "ctco_001", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -208,3 +257,37 @@ func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing
require.Less(t, largeAllocated, smallAllocated*30,
"10x more input items must not cause quadratic whole-body allocation growth")
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyClosesInvalidIDReferences(t *testing.T) {
body := []byte(`{"type":"response.create","input":[
{"type":"custom_tool_call","id":"ctc_call","call_id":"call_custom","name":"apply_patch","input":"patch"},
{"type":"custom_tool_call_output","id":"ctco_bad","call_id":"call_custom","output":"done"},
{"type":"item_reference","id":"ctco_bad"},
{"type":"future_item","id":"item_future","payload":"keep"}
]}`)
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
t.Run(accountType, func(t *testing.T) {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
require.NoError(t, err)
require.True(t, changed)
require.Len(t, gjson.GetBytes(normalized, "input").Array(), 3)
require.Equal(t, "ctc_call", gjson.GetBytes(normalized, "input.0.id").String())
require.Equal(t, "call_custom", gjson.GetBytes(normalized, "input.1.call_id").String())
require.False(t, gjson.GetBytes(normalized, "input.1.id").Exists())
require.Equal(t, "item_future", gjson.GetBytes(normalized, "input.2.id").String())
second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
require.NoError(t, err)
require.False(t, changedAgain)
require.JSONEq(t, string(normalized), string(second))
})
}
}
@@ -125,11 +125,13 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
if account.Platform != PlatformGrok && !tempUnscheduled {
shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
}
return newOpenAIUpstreamFailoverError(
return s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
resp.Header,
respBody,
upstreamMsg,
shouldDisable,
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
)
}
@@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
promptCacheKey = strings.TrimSpace(promptCacheKey)
compatPromptCacheInjected := false
if promptCacheKey == "" && account.Type == AccountTypeOAuth && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
if promptCacheKey == "" && account.UsesOpenAICodexProtocol() && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
promptCacheKey = deriveCompatPromptCacheKey(&chatReq, upstreamModel)
compatPromptCacheInjected = promptCacheKey != ""
}
@@ -203,7 +203,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
logger.L().Debug("openai chat_completions: model mapping applied", logFields...)
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
var reqBody map[string]any
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
@@ -234,6 +234,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return nil, fmt.Errorf("remarshal after codex transform: %w", err)
}
}
// Codex transforms may normalize the model after the initial mapping pass;
// record the final slug immediately before policy/auth/upstream dispatch.
SetOpsUpstreamModel(c, upstreamModel)
if account.Type == AccountTypeAPIKey {
if trimmedKey := strings.TrimSpace(promptCacheKey); trimmedKey != "" {
@@ -481,6 +484,9 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", message)
return nil, fmt.Errorf("upstream response failed: %s", message)
}
if strings.TrimSpace(finalResponse.Status) == "completed" {
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false)
}
if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, finalResponse.Model) && !hasBillableGrokChatUsage(usage) {
upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id"))
@@ -596,6 +602,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen)
var streamFailoverErr *UpstreamFailoverError
var streamNonFailoverErr error
terminalEventType := ""
// Grok chat bridge reuses Responses SSE; count native search tools for surcharge.
searchCount := 0
streamSearchSeen := make(map[string]struct{})
@@ -661,9 +668,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
observer.ObserveOpenAI([]byte(payload), event.Type)
refusalDetector.ObservePayload([]byte(payload))
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type)
if isTerminalEvent {
terminalEventType = strings.TrimSpace(event.Type)
if event.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
}
@@ -671,7 +680,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
}
}
if strings.TrimSpace(event.Type) == "response.failed" {
if strings.TrimSpace(event.Type) == "response.failed" || strings.TrimSpace(event.Type) == "error" {
payloadBytes := []byte(payload)
message := extractOpenAISSEErrorMessage(payloadBytes)
if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit {
@@ -705,7 +714,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
return true
}
if openAIStreamFailedEventShouldFailover(payloadBytes, message) {
shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message)
if strings.TrimSpace(event.Type) == "error" {
shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message)
}
if !clientOutputStarted && shouldFailover {
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
return true
}
@@ -874,6 +887,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
if !clientDisconnected {
c.Writer.Flush()
}
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected)
return resultWithUsage(), nil
}
@@ -75,6 +75,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
// 2. Resolve model mapping (same as ForwardAsChatCompletions)
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
SetOpsUpstreamModel(c, upstreamModel)
grokCacheIdentity := ""
if account.Platform == PlatformGrok {
// Resolve before image bridging or other body rewrites so the fallback is
@@ -489,6 +489,46 @@ func TestForwardAsChatCompletions_StreamContextWindowResponseFailedReturnsErrorW
require.NotContains(t, rec.Body.String(), "[DONE]")
}
func TestForwardAsChatCompletions_StreamBareErrorAfterOutputDoesNotFailOver(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"stream":true}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
`data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}`,
"",
`event: error`,
`data: {"type":"error","error":{"type":"server_error","code":"server_error","message":"temporary upstream failure"}}`,
"",
`data: [DONE]`,
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{httpUpstream: upstream}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
require.Error(t, err)
require.NotNil(t, result)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr))
require.Contains(t, rec.Body.String(), "partial")
require.Contains(t, rec.Body.String(), "temporary upstream failure")
require.NotContains(t, rec.Body.String(), "[DONE]")
}
func TestForwardAsChatCompletions_StreamCyberPolicyNoFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -49,24 +49,33 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if normalized {
body = normalizedBody
}
legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body)
if legacyIngressErr != nil {
return nil, legacyIngressErr
}
if legacyIngressChanged {
body = legacyIngressBody
}
// 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
// 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
// 同一份坏定义在账号池里反复重放。
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
if toolSchemaErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
if shouldSanitizeOpenAIResponsesToolSchemas(account.Platform) {
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
if toolSchemaErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
}
if toolSchemaSanitized {
body = sanitizedToolBody
}
patternSanitizedBody, patternSanitized, patternErr := sanitizeOpenAIResponsesToolSchemaPatterns(body)
if patternErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", patternErr)
}
if patternSanitized {
body = patternSanitizedBody
}
}
if toolSchemaSanitized {
body = sanitizedToolBody
}
patternSanitizedBody, patternSanitized, patternErr := sanitizeOpenAIResponsesToolSchemaPatterns(body)
if patternErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", patternErr)
}
if patternSanitized {
body = patternSanitizedBody
}
if account.IsOpenAI() && account.IsOAuth() {
if account.IsOpenAIOAuthLike() {
reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body)
if reasoningErr != nil {
return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr)
@@ -75,7 +84,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
body = reasoningBody
}
}
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
if liteErr != nil {
setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
@@ -132,11 +141,28 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if account.IsAnthropicProtocol() {
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
}
if account.IsOpenAIApiKey() {
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil {
return nil, normalizeErr
} else if changed {
body = normalized
originalBody = normalized
}
if normalized, changed, normalizeErr := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, isOpenAIResponsesCompactPath(c)); normalizeErr != nil {
return nil, normalizeErr
} else if changed {
body = normalized
originalBody = normalized
}
requestView = newOpenAIRequestView(body)
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel = reqModel
}
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
if account.Platform == PlatformOpenAI && (account.Type == AccountTypeAPIKey || account.Type == AccountTypeOAuth) {
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
if sanitizeErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
@@ -307,35 +333,26 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
}
billingModel := account.GetMappedModel(reqModel)
if billingModel != reqModel {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI)
reqModel = billingModel
markPatchSet("model", billingModel)
}
upstreamModel := billingModel
isCompactRequest := compactPath
compactMapped := false
requestedModel := reqModel
billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest)
if isCompactRequest {
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
if compactMappedModel != "" && compactMappedModel != billingModel {
compactMapped = true
upstreamModel = compactMappedModel
reqModel = compactMappedModel
markPatchSet("model", compactMappedModel)
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI)
if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" {
upstreamModel = compactModel
}
}
if !compactMapped {
modelForNormalize := reqModel
if modelForNormalize == "" {
modelForNormalize = requestView.Model
}
upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize)
if upstreamModel != "" && upstreamModel != modelForNormalize {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI)
reqModel = upstreamModel
markPatchSet("model", upstreamModel)
if billingModel != requestedModel {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI)
}
reqModel = billingModel
if upstreamModel != requestedModel {
markPatchSet("model", upstreamModel)
}
if upstreamModel != billingModel {
if isCompactRequest {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI)
} else {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", billingModel, upstreamModel, account.Name, account.Type, isCodexCLI)
}
}
if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
@@ -430,7 +447,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
@@ -532,7 +549,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
}
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() {
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 &&
!account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() {
markPatchDelete("previous_response_id")
}
if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
@@ -571,7 +589,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
@@ -616,6 +634,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
requestView = newOpenAIRequestView(body)
}
}
// Run after orphan-output filtering and all request-map rebuilds so a
// compaction trigger cannot remain ahead of surviving history items.
if normalizedBody, changed, normalizeErr := NormalizeCompactionTriggerInputOrder(body); normalizeErr != nil {
return nil, fmt.Errorf("normalize compaction trigger order: %w", normalizeErr)
} else if changed {
body = normalizedBody
requestView = newOpenAIRequestView(body)
reqBody = nil
}
imageBillingModel := ""
imageSizeTier := ""
imageInputSize := ""
@@ -636,12 +663,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
imageSizeTier = imageCfg.SizeTier
imageInputSize = imageCfg.InputSize
}
// Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
SetOpsUpstreamModel(c, upstreamModel)
// 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
@@ -879,8 +906,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
httpInvalidEncryptedContentRetryTried := false
compactModelFallbackRetried := false
agentTaskRecoveryTried := false
rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body)
rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
for {
// Build upstream request
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
@@ -982,6 +1010,23 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
continue
}
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, resp.StatusCode, upstreamMsg, respBody, compactModelFallbackRetried,
); retry {
fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
body = retryBody
requestView = newOpenAIRequestView(body)
reqBody = nil
upstreamModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
account.Name, fromModel, fallbackModel, upstreamCode,
)
continue
}
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
@@ -1003,15 +1048,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
})
shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
return nil, newOpenAIUpstreamFailoverError(
return nil, s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
resp.Header,
respBody,
upstreamMsg,
shouldDisable,
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
)
}
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
}
defer func() { _ = resp.Body.Close() }()
@@ -1029,6 +1076,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if reqStream {
streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue)
if err != nil {
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
); retry {
body = retryBody
requestView = newOpenAIRequestView(body)
upstreamModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
continue
}
}
return nil, err
}
usage = streamResult.usage
@@ -1040,6 +1099,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} else {
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
if err != nil {
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
); retry {
body = retryBody
requestView = newOpenAIRequestView(body)
upstreamModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
continue
}
}
return nil, err
}
usage = nonStreamResult.usage
@@ -1052,7 +1123,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
// Extract and save Codex usage snapshot from response headers (for OAuth accounts).
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == AccountTypeOAuth && !account.IsShadow() {
if account.UsesOpenAICodexProtocol() && !account.IsShadow() {
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
}
@@ -1108,6 +1179,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
case AccountTypeOAuth:
// OAuth accounts use ChatGPT internal API
targetURL = chatgptCodexURL
case AccountTypeSetupToken:
if account.IsOpenAIOAuthLike() {
targetURL = chatgptCodexURL
} else {
targetURL = openaiPlatformAPIURL
}
case AccountTypeAPIKey:
// API Key accounts use Platform API or custom base URL
baseURL := account.GetOpenAIBaseURL()
@@ -1148,7 +1225,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
// Set headers specific to OAuth accounts (ChatGPT internal API)
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
req.Host = "chatgpt.com"
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
@@ -1168,7 +1245,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
// 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
@@ -1222,7 +1299,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
+141 -39
View File
@@ -116,9 +116,9 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
}
// xAI can reject encrypted reasoning copied from a response produced under
// another account or cache identity. Retry once with the same routing and
// credential after removing only the rejected encrypted reasoning payload.
// xAI can reject encrypted reasoning or a compaction blob copied from a
// different decoder/cache context. Retry once on the same account after
// preserving visible summaries and removing only opaque replay state.
if attempt > 0 || resp.StatusCode != http.StatusBadRequest {
break
}
@@ -126,14 +126,22 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
if resp.Body != nil {
_ = resp.Body.Close()
}
if !isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) {
invalidEncryptedContent := isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody)
if !invalidEncryptedContent && !isGrokCompactionReplayDecodeError(resp.StatusCode, respBody) {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
break
}
retryBody, changed, trimErr := trimGrokInvalidEncryptedContentRetryBody(patchedBody)
var retryBody []byte
var changed bool
var trimErr error
if invalidEncryptedContent {
retryBody, changed, trimErr = trimGrokInvalidEncryptedContentRetryBody(patchedBody)
} else {
retryBody, changed, trimErr = sanitizeGrokCompactionReplayBody(patchedBody)
}
if trimErr != nil {
return nil, fmt.Errorf("prepare Grok invalid encrypted_content retry: %w", trimErr)
return nil, fmt.Errorf("prepare Grok replay decode retry: %w", trimErr)
}
if !changed {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
@@ -141,7 +149,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
}
patchedBody = retryBody
slog.Info("grok_invalid_encrypted_content_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "")
slog.Info("grok_replay_decode_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "")
}
defer func() { _ = resp.Body.Close() }()
@@ -262,22 +270,9 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool {
// {"code":"invalid-argument","error":"Could not decrypt the provided encrypted_content."}
// {"error":{"message":"Could not decrypt the provided encrypted_content."}}
code := strings.TrimSpace(gjson.GetBytes(body, "code").String())
message := ""
errNode := gjson.GetBytes(body, "error")
switch {
case errNode.Type == gjson.String:
message = errNode.String()
case errNode.IsObject():
message = firstNonEmpty(errNode.Get("message").String(), errNode.Get("error").String())
if code == "" {
code = strings.TrimSpace(errNode.Get("code").String())
}
default:
message = gjson.GetBytes(body, "message").String()
}
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
if normalizedMessage == "" {
return false
if code == "" && errNode.IsObject() {
code = strings.TrimSpace(errNode.Get("code").String())
}
if strings.EqualFold(code, "invalid_encrypted_content") {
@@ -287,13 +282,102 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool {
if !strings.EqualFold(code, "invalid-argument") && code != "" {
return false
}
// Nested OpenAI-style envelopes may omit top-level code; require decrypt text.
if code == "" && !strings.Contains(normalizedMessage, "decrypt") {
for _, candidate := range grokStructuredErrorMessageCandidates(body) {
normalizedMessage := strings.ToLower(candidate)
// Nested OpenAI-style envelopes may omit top-level code; require decrypt text.
if code == "" && !strings.Contains(normalizedMessage, "decrypt") {
continue
}
if strings.Contains(normalizedMessage, "encrypted_content") &&
(strings.Contains(normalizedMessage, "decrypt") || strings.Contains(normalizedMessage, "unmodified")) {
return true
}
}
return false
}
func isGrokCompactionReplayDecodeError(statusCode int, body []byte) bool {
if statusCode != http.StatusBadRequest || len(body) == 0 {
return false
}
return strings.Contains(normalizedMessage, "encrypted_content") &&
(strings.Contains(normalizedMessage, "decrypt") ||
strings.Contains(normalizedMessage, "unmodified"))
for _, candidate := range grokStructuredErrorMessageCandidates(body) {
message := strings.ToLower(candidate)
decodeSignal := strings.Contains(message, "decode") ||
strings.Contains(message, "deserialize") ||
strings.Contains(message, "decoder")
replaySignal := strings.Contains(message, "compaction") ||
strings.Contains(message, "summary") ||
strings.Contains(message, "encrypted_content") ||
strings.Contains(message, "response history")
if decodeSignal && replaySignal {
return true
}
}
return false
}
func sanitizeGrokCompactionReplayBody(body []byte) ([]byte, bool, error) {
converted, err := convertOpenAICompactInputsForGrok(body)
if err != nil {
return nil, false, fmt.Errorf("convert Grok compaction replay: %w", err)
}
var requestBody map[string]any
decoder := json.NewDecoder(bytes.NewReader(converted))
decoder.UseNumber()
if err := decoder.Decode(&requestBody); err != nil {
return nil, false, err
}
changed := !bytes.Equal(converted, body)
if trimOpenAIEncryptedReasoningItems(requestBody) {
changed = true
}
if dropEmptyGrokReplayReasoning(requestBody) {
changed = true
}
if previousID, _ := requestBody["previous_response_id"].(string); strings.TrimSpace(previousID) != "" && !HasFunctionCallOutput(requestBody) {
delete(requestBody, "previous_response_id")
if _, exists := requestBody["store"]; !exists {
requestBody["store"] = false
}
changed = true
}
if !changed {
return body, false, nil
}
retryBody, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return nil, false, err
}
return retryBody, true, nil
}
func dropEmptyGrokReplayReasoning(requestBody map[string]any) bool {
items, ok := requestBody["input"].([]any)
if !ok {
return false
}
filtered := items[:0]
changed := false
for _, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(grokStringValue(item["type"])) != "reasoning" {
filtered = append(filtered, rawItem)
continue
}
summary, _ := item["summary"].([]any)
content, hasContent := item["content"]
_, hasEncrypted := item["encrypted_content"]
if hasEncrypted || len(summary) > 0 || (hasContent && content != nil) {
filtered = append(filtered, rawItem)
continue
}
changed = true
}
if changed {
requestBody["input"] = filtered
}
return changed
}
// requestHasGrokEncryptedReasoning reports whether the outbound Responses body
@@ -338,7 +422,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) {
return body, false
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &req); err != nil {
return body, false
}
messages, ok := req["messages"].([]any)
@@ -372,7 +456,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) {
if !changed {
return body, false
}
out, err := json.Marshal(req)
out, err := marshalOpenAIUpstreamJSON(req)
if err != nil {
return body, false
}
@@ -495,6 +579,10 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro
if err != nil {
return nil, err
}
out, err = sanitizeGrokResponsesModelInput(out)
if err != nil {
return nil, err
}
out, err = stripRedundantGrokViewImageTool(out)
if err != nil {
return nil, err
@@ -666,13 +754,13 @@ func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) {
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil {
return nil, err
}
if !deleteJSONFields(payload, grokResponsesUnsupportedRecursiveFields) {
return body, nil
}
return json.Marshal(payload)
return marshalOpenAIUpstreamJSON(payload)
}
func deleteJSONFields(value any, fields map[string]struct{}) bool {
@@ -893,13 +981,10 @@ var grokResponsesSupportedToolTypes = map[string]struct{}{
func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
tools := gjson.GetBytes(body, "tools")
if !tools.Exists() {
if gjson.GetBytes(body, "tool_choice").Exists() {
return sjson.DeleteBytes(body, "tool_choice")
}
return body, nil
return deleteGrokOrphanToolControls(body)
}
if !tools.IsArray() {
return body, nil
return deleteGrokOrphanToolControls(body)
}
rawTools := tools.Array()
@@ -911,11 +996,11 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
raw := json.RawMessage(tool.Raw)
if toolType == "function" && (!tool.Get("parameters").Exists() || tool.Get("parameters").Type == gjson.Null) {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
if err := decodeOpenAIJSONUseNumber(raw, &payload); err != nil {
return nil, err
}
payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
encoded, err := json.Marshal(payload)
encoded, err := marshalOpenAIUpstreamJSON(payload)
if err != nil {
return nil, err
}
@@ -942,6 +1027,9 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
return nil, err
}
}
if len(filteredTools) == 0 {
return deleteGrokOrphanToolControls(body)
}
toolChoice := gjson.GetBytes(body, "tool_choice")
if !toolChoice.Exists() {
@@ -956,6 +1044,20 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
return body, nil
}
func deleteGrokOrphanToolControls(body []byte) ([]byte, error) {
var err error
for _, field := range []string{"tool_choice", "parallel_tool_calls"} {
if !gjson.GetBytes(body, field).Exists() {
continue
}
body, err = sjson.DeleteBytes(body, field)
if err != nil {
return nil, err
}
}
return body, nil
}
func shouldDropGrokToolChoice(toolChoice gjson.Result, tools []json.RawMessage) bool {
if len(tools) == 0 {
return true
@@ -1014,7 +1116,7 @@ func (s *OpenAIGatewayService) bridgeGrokComposerImageInputs(
}
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, OpenAIUsage{}, false, fmt.Errorf("parse grok composer image bridge request: %w", err)
}
@@ -0,0 +1,380 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// sanitizeGrokResponsesModelInput converts replay items from OpenAI and Chat
// shapes into the subset accepted by xAI's ModelInput decoder. Call IDs are
// assigned in a separate pass so an output can safely appear before its call.
func sanitizeGrokResponsesModelInput(body []byte) ([]byte, error) {
input := gjson.GetBytes(body, "input")
if !input.Exists() || input.Type == gjson.String {
return body, nil
}
if input.IsObject() {
var err error
body, err = sjson.SetRawBytes(body, "input", []byte("["+input.Raw+"]"))
if err != nil {
return nil, fmt.Errorf("wrap Grok Responses model input: %w", err)
}
input = gjson.GetBytes(body, "input")
}
if !input.IsArray() {
return body, nil
}
var items []any
decoder := json.NewDecoder(bytes.NewReader([]byte(input.Raw)))
decoder.UseNumber()
if err := decoder.Decode(&items); err != nil {
return nil, fmt.Errorf("parse Grok Responses model input: %w", err)
}
callIDs, outputIDs := pairGrokReplayCallIDs(items)
filtered := make([]any, 0, len(items))
for index, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
if text, ok := rawItem.(string); ok && strings.TrimSpace(text) != "" {
filtered = append(filtered, map[string]any{"type": "message", "role": "user", "content": text})
}
continue
}
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
if role == "tool" || role == "function" || isGrokReplayOutputType(itemType) {
output := firstNonNilGrokJSONValue(item["output"], item["content"], item["results"])
filtered = append(filtered, map[string]any{
"type": "function_call_output",
"call_id": outputIDs[index],
"output": grokModelInputString(output, "(empty)"),
})
continue
}
switch itemType {
case "text", "input_text", "output_text":
text := strings.TrimSpace(grokStringValue(item["text"]))
if text == "" {
continue
}
if role == "" {
role = "user"
}
item = map[string]any{"type": "message", "role": role, "content": text}
itemType = "message"
case "custom_tool_call", "tool_search_call":
originalType := itemType
item["type"] = "function_call"
itemType = "function_call"
if strings.TrimSpace(grokStringValue(item["name"])) == "" {
if originalType == "tool_search_call" {
item["name"] = "tool_search"
}
}
}
if itemType == "" && role != "" {
itemType = "message"
item["type"] = itemType
}
if itemType == "message" {
if role == "" {
role = "user"
item["role"] = role
}
content, keep := sanitizeGrokMessageContent(item["content"])
if !keep {
continue
}
item["content"] = content
if role == "assistant" && !grokIsCompleteOutputMessage(item) {
if text, ok := collapseGrokAssistantOutputText(content); ok {
item["content"] = text
}
delete(item, "id")
delete(item, "status")
}
} else if itemType == "reasoning" {
delete(item, "status")
if content, exists := item["content"]; exists && content == nil {
delete(item, "content")
}
} else if itemType == "function_call" {
name := strings.TrimSpace(grokStringValue(item["name"]))
arguments := item["arguments"]
if function, ok := item["function"].(map[string]any); ok {
if name == "" {
name = strings.TrimSpace(grokStringValue(function["name"]))
}
if arguments == nil {
arguments = function["arguments"]
}
}
if arguments == nil {
arguments = firstNonNilGrokJSONValue(item["input"], item["query"])
if _, custom := item["input"]; custom {
arguments = map[string]any{"input": arguments}
}
}
if name == "" {
name = "unknown_tool"
}
item["call_id"] = callIDs[index]
item["name"] = name
item["arguments"] = grokModelInputString(arguments, "{}")
for _, field := range []string{"id", "status", "tool_call_id", "function", "input", "query", "execution"} {
delete(item, field)
}
}
if shouldStripOpenAIResponsesNonPairCallID(itemType) {
delete(item, "call_id")
}
filtered = append(filtered, item)
}
encoded, err := json.Marshal(filtered)
if err != nil {
return nil, fmt.Errorf("serialize Grok Responses model input: %w", err)
}
updated, err := sjson.SetRawBytes(body, "input", encoded)
if err != nil {
return nil, fmt.Errorf("set Grok Responses model input: %w", err)
}
return updated, nil
}
func pairGrokReplayCallIDs(items []any) (map[int]string, map[int]string) {
callIDs := make(map[int]string)
outputIDs := make(map[int]string)
aliases := make(map[string]string)
conflictingAliases := make(map[string]struct{})
pendingCalls := make([]string, 0)
nextID := 0
synthetic := func() string {
nextID++
return fmt.Sprintf("grok_replay_call_%d", nextID)
}
registerAlias := func(alias, canonical string) {
alias = strings.TrimSpace(alias)
if alias == "" {
return
}
if _, conflict := conflictingAliases[alias]; conflict {
return
}
if existing, exists := aliases[alias]; exists && existing != canonical {
delete(aliases, alias)
conflictingAliases[alias] = struct{}{}
return
}
aliases[alias] = canonical
}
for index, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
if !isGrokReplayCallType(itemType) {
continue
}
callID := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"])
if callID == "" {
callID = synthetic()
}
callIDs[index] = callID
pendingCalls = append(pendingCalls, callID)
registerAlias(callID, callID)
registerAlias(grokStringValue(item["call_id"]), callID)
registerAlias(grokStringValue(item["tool_call_id"]), callID)
registerAlias(grokStringValue(item["id"]), callID)
}
consumedCalls := make(map[string]struct{})
consumeNextCall := func() string {
for _, callID := range pendingCalls {
if _, consumed := consumedCalls[callID]; consumed {
continue
}
consumedCalls[callID] = struct{}{}
return callID
}
return synthetic()
}
for index, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
if role != "tool" && role != "function" && !isGrokReplayOutputType(itemType) {
continue
}
alias := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"], item["id"])
_, conflict := conflictingAliases[alias]
if canonical := aliases[alias]; canonical != "" && !conflict {
outputIDs[index] = canonical
consumedCalls[canonical] = struct{}{}
} else if firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) != "" {
// Explicit call identifiers remain authoritative even when an invalid
// transcript reuses the same identifier for more than one call.
outputIDs[index] = alias
} else if conflict {
// An item ID shared by multiple calls cannot safely identify either
// one. Keep it out of the call_id namespace and leave the output orphaned.
outputIDs[index] = synthetic()
} else {
outputIDs[index] = consumeNextCall()
}
}
return callIDs, outputIDs
}
func isGrokReplayCallType(itemType string) bool {
switch itemType {
case "function_call", "custom_tool_call", "tool_search_call":
return true
default:
return false
}
}
func isGrokReplayOutputType(itemType string) bool {
switch itemType {
case "function_call_output", "custom_tool_call_output", "tool_search_output", "tool_search_call_output":
return true
default:
return false
}
}
func firstNonEmptyGrokString(values ...any) string {
for _, value := range values {
if text := strings.TrimSpace(grokStringValue(value)); text != "" {
return text
}
}
return ""
}
func firstNonNilGrokJSONValue(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func sanitizeGrokMessageContent(content any) (any, bool) {
switch value := content.(type) {
case nil:
return nil, false
case string:
return value, strings.TrimSpace(value) != ""
case []any:
filtered := make([]any, 0, len(value))
for _, rawPart := range value {
part, ok := rawPart.(map[string]any)
if !ok {
if text, isText := rawPart.(string); !isText || strings.TrimSpace(text) != "" {
filtered = append(filtered, rawPart)
}
continue
}
switch strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) {
case "text", "input_text", "output_text":
if strings.TrimSpace(grokStringValue(part["text"])) == "" {
continue
}
case "image_url", "input_image":
if !grokContentPartHasImageURL(part) {
continue
}
}
filtered = append(filtered, part)
}
return filtered, len(filtered) > 0
default:
return content, true
}
}
func grokContentPartHasImageURL(part map[string]any) bool {
for _, field := range []string{"file_id", "file_data"} {
if strings.TrimSpace(grokStringValue(part[field])) != "" {
return true
}
}
raw := firstNonNilGrokJSONValue(part["image_url"], part["file_url"])
switch value := raw.(type) {
case string:
value = strings.TrimSpace(value)
return value != "" && !isEmptyBase64DataURI(value)
case map[string]any:
url := strings.TrimSpace(grokStringValue(value["url"]))
return url != "" && !isEmptyBase64DataURI(url)
default:
return false
}
}
func grokStringValue(value any) string {
text, _ := value.(string)
return text
}
func grokModelInputString(value any, fallback string) string {
if text, ok := value.(string); ok {
if strings.TrimSpace(text) == "" {
return fallback
}
return text
}
if value == nil {
return fallback
}
encoded, err := json.Marshal(value)
if err != nil || len(encoded) == 0 || string(encoded) == "null" {
return fallback
}
return string(encoded)
}
func grokIsCompleteOutputMessage(item map[string]any) bool {
return strings.TrimSpace(grokStringValue(item["id"])) != "" &&
strings.TrimSpace(grokStringValue(item["status"])) != ""
}
func collapseGrokAssistantOutputText(content any) (string, bool) {
parts, ok := content.([]any)
if !ok || len(parts) == 0 {
return "", false
}
var text strings.Builder
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok || strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) != "output_text" {
return "", false
}
value, ok := part["text"].(string)
if !ok {
return "", false
}
text.WriteString(value)
}
if strings.TrimSpace(text.String()) == "" {
return "", false
}
return text.String(), true
}
@@ -0,0 +1,177 @@
package service
import (
"net/http"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSanitizeGrokResponsesModelInputNormalizesReplayHistory(t *testing.T) {
body := []byte(`{
"input":[
{"role":"system","content":"rules"},
{"type":"message","role":"assistant","id":"msg_partial","content":[{"type":"output_text","text":"done"}]},
{"type":"reasoning","id":"rs_1","status":"completed","content":null,"summary":[{"type":"summary_text","text":"keep"}]},
{"type":"message","role":"assistant","id":"msg_complete","status":"completed","content":[{"type":"output_text","text":"kept"}]}
]}`)
patched, err := sanitizeGrokResponsesModelInput(body)
require.NoError(t, err)
require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
require.Equal(t, "done", gjson.GetBytes(patched, "input.1.content").String())
require.False(t, gjson.GetBytes(patched, "input.1.id").Exists())
require.False(t, gjson.GetBytes(patched, "input.2.status").Exists())
require.False(t, gjson.GetBytes(patched, "input.2.content").Exists())
require.Equal(t, "keep", gjson.GetBytes(patched, "input.2.summary.0.text").String())
require.Equal(t, "msg_complete", gjson.GetBytes(patched, "input.3.id").String())
require.Equal(t, "completed", gjson.GetBytes(patched, "input.3.status").String())
}
func TestSanitizeGrokResponsesModelInputStripsOnlyNonPairCallIDs(t *testing.T) {
body := []byte(`{"input":[
{"type":"message","role":"user","call_id":"remove_message","content":"continue"},
{"type":"reasoning","call_id":"remove_reasoning","summary":[]},
{"type":"image_generation_call","call_id":"remove_image","id":"ig_1","status":"completed"},
{"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
{"type":"function_call_output","call_id":"keep_function","output":"ok"}
]}`)
patched, err := sanitizeGrokResponsesModelInput(body)
require.NoError(t, err)
for i := 0; i < 3; i++ {
require.False(t, gjson.GetBytes(patched, "input."+strconv.Itoa(i)+".call_id").Exists())
}
require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.3.call_id").String())
require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.4.call_id").String())
}
func TestSanitizeGrokResponsesModelInputPairsCallsInTwoPasses(t *testing.T) {
body := []byte(`{
"input":[
{"type":"function_call_output","output":{"before":true,"large_id":9007199254740993}},
{"type":"function_call","function":{"name":"lookup","arguments":{"query":"x","large_id":9007199254740993}}},
{"type":"custom_tool_call","id":"custom_1","name":"apply_patch","input":"patch"},
{"type":"custom_tool_call_output","tool_call_id":"custom_1","output":["ok"]},
{"type":"tool_search_call","id":"search_1","arguments":{"query":"repo"}},
{"type":"tool_search_output","call_id":"search_1","results":{"groups":["repo"]}},
{"role":"tool","tool_call_id":"orphan","content":{"value":1}}
]}`)
patched, err := sanitizeGrokResponsesModelInput(body)
require.NoError(t, err)
generated := gjson.GetBytes(patched, "input.1.call_id").String()
require.NotEmpty(t, generated)
require.Equal(t, generated, gjson.GetBytes(patched, "input.0.call_id").String())
require.JSONEq(t, `{"before":true,"large_id":9007199254740993}`, gjson.GetBytes(patched, "input.0.output").String())
require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.0.output").String(), "large_id").Raw)
require.Equal(t, "lookup", gjson.GetBytes(patched, "input.1.name").String())
require.JSONEq(t, `{"query":"x","large_id":9007199254740993}`, gjson.GetBytes(patched, "input.1.arguments").String())
require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.1.arguments").String(), "large_id").Raw)
customCallID := gjson.GetBytes(patched, "input.2.call_id").String()
require.NotEmpty(t, customCallID)
require.NotEqual(t, "custom_1", customCallID)
require.JSONEq(t, `{"input":"patch"}`, gjson.GetBytes(patched, "input.2.arguments").String())
require.Equal(t, customCallID, gjson.GetBytes(patched, "input.3.call_id").String())
require.JSONEq(t, `["ok"]`, gjson.GetBytes(patched, "input.3.output").String())
require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.4.name").String())
searchCallID := gjson.GetBytes(patched, "input.4.call_id").String()
require.NotEmpty(t, searchCallID)
require.NotEqual(t, "search_1", searchCallID)
require.Equal(t, searchCallID, gjson.GetBytes(patched, "input.5.call_id").String())
require.JSONEq(t, `{"groups":["repo"]}`, gjson.GetBytes(patched, "input.5.output").String())
require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.6.type").String())
require.Equal(t, "orphan", gjson.GetBytes(patched, "input.6.call_id").String())
}
func TestSanitizeGrokResponsesModelInputMapsAllItemAliasesAndRejectsConflicts(t *testing.T) {
body := []byte(`{"input":[
{"type":"function_call","id":"fc_alias","call_id":"call_canonical","name":"one","arguments":"{}"},
{"type":"function_call_output","id":"fc_alias","output":"ok"},
{"type":"function_call","id":"duplicate_item","name":"two","arguments":"{}"},
{"type":"function_call","id":"duplicate_item","name":"three","arguments":"{}"},
{"type":"function_call_output","id":"duplicate_item","output":"ambiguous"}
]}`)
patched, err := sanitizeGrokResponsesModelInput(body)
require.NoError(t, err)
require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.0.call_id").String())
require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.1.call_id").String())
require.False(t, gjson.GetBytes(patched, "input.0.id").Exists())
secondCallID := gjson.GetBytes(patched, "input.2.call_id").String()
thirdCallID := gjson.GetBytes(patched, "input.3.call_id").String()
ambiguousOutputID := gjson.GetBytes(patched, "input.4.call_id").String()
require.NotEmpty(t, secondCallID)
require.NotEmpty(t, thirdCallID)
require.NotEmpty(t, ambiguousOutputID)
require.NotEqual(t, "duplicate_item", secondCallID)
require.NotEqual(t, secondCallID, thirdCallID)
require.NotEqual(t, secondCallID, ambiguousOutputID)
require.NotEqual(t, thirdCallID, ambiguousOutputID)
}
func TestPatchGrokResponsesBodyCombinesAdditionalToolsAndDropsOrphanControls(t *testing.T) {
body := []byte(`{
"input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]},{"role":"user","content":"hi"}],
"tools":[{"type":"function","name":"lookup"}],
"tool_choice":"auto","parallel_tool_calls":true
}`)
patched, err := patchGrokResponsesBody(body, "grok-4.5")
require.NoError(t, err)
require.Equal(t, int64(1), gjson.GetBytes(patched, "tools.#").Int())
require.Equal(t, "lookup", gjson.GetBytes(patched, "tools.0.name").String())
require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
withoutTools, err := patchGrokResponsesBody([]byte(`{"input":"hi","tool_choice":"auto","parallel_tool_calls":true}`), "grok-4.5")
require.NoError(t, err)
require.False(t, gjson.GetBytes(withoutTools, "tool_choice").Exists())
require.False(t, gjson.GetBytes(withoutTools, "parallel_tool_calls").Exists())
for _, malformedTools := range []string{"null", `{}`, `"invalid"`} {
malformedBody := []byte(`{"input":"hi","tools":` + malformedTools + `,"tool_choice":"auto","parallel_tool_calls":true}`)
patched, err := patchGrokResponsesBody(malformedBody, "grok-4.5")
require.NoError(t, err)
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists(), string(patched))
require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists(), string(patched))
}
}
func TestSanitizeGrokCompactionReplayBodyPreservesVisibleSummary(t *testing.T) {
body := []byte(`{
"previous_response_id":"resp_stale",
"input":[{"type":"compaction","encrypted_content":"opaque","summary":[{"type":"summary_text","text":"visible history"}]},{"type":"message","role":"user","content":"continue"}]
}`)
patched, changed, err := sanitizeGrokCompactionReplayBody(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(patched, "previous_response_id").Exists())
require.False(t, gjson.GetBytes(patched, `input.#(type=="reasoning")`).Exists())
require.Contains(t, gjson.GetBytes(patched, "input.0.content.0.text").String(), "visible history")
require.Equal(t, "continue", gjson.GetBytes(patched, "input.1.content").String())
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"message":"failed to deserialize compaction summary history"}}`)))
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"detail":"could not decode compaction blob"}`)))
}
func TestGrokStructuredErrorCandidatesDoNotShadowTopLevelMessages(t *testing.T) {
shadowedInvalidEncrypted := []byte(`{"code":"invalid-argument","error":{"type":"invalid_request_error"},"message":"Could not decrypt encrypted_content because it was modified"}`)
require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusBadRequest, shadowedInvalidEncrypted))
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not decode compaction history"}`)))
}
func TestGrokDecoderCompatibility422FailsOverWithoutCooldown(t *testing.T) {
body := []byte(`{"detail":"data did not match any variant of untagged enum ModelInput at input[3]"}`)
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, body))
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"message":"could not decode ModelInput at input.3"}`)))
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not deserialize ModelInput at input[3]"}`)))
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"Failed to deserialize the JSON body into the target type: messages[1]: data did not match any variant of untagged enum Content at line 1 column 6577"}`)))
require.True(t, (&OpenAIGatewayService{}).shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body))
decision := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, body, "grok-4.5")
require.False(t, decision.ShouldCooldown)
require.Equal(t, GrokFailureNone, decision.Class)
require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"message":"invalid user parameter"}}`)))
require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"messages[1].content is required"}`)))
require.False(t, isGrokDecoderCompatibilityError(http.StatusBadRequest, body))
}
@@ -281,7 +281,7 @@ func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) {
func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) {
patched, err := patchGrokResponsesBody(
[]byte(`{"input":"hi","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"wait","parameters":null}]}`),
[]byte(`{"input":"hi","tools":[{"type":"function","name":"lookup","large_id":9007199254740993},{"type":"function","name":"wait","parameters":null}]}`),
"grok-4.5",
)
require.NoError(t, err)
@@ -289,6 +289,7 @@ func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) {
require.Equal(t, "object", tool.Get("parameters.type").String(), string(patched))
require.True(t, tool.Get("parameters.properties").IsObject(), string(patched))
}
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "tools.0.large_id").Raw)
}
func TestNormalizeGrokChatReasoningEffort(t *testing.T) {
@@ -316,7 +317,7 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) {
"tools": [
{"type": "function", "name": "kept_fn", "external_web_access": true, "parameters": {"type": "object", "properties": {"q": {"type": "string", "external_web_access": true}}}}
],
"metadata": {"external_web_access": false}
"metadata": {"external_web_access": false, "large_id": 9007199254740993}
}`)
patched, err := patchGrokResponsesBody(body, "grok-4.3")
@@ -324,6 +325,17 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) {
require.True(t, json.Valid(patched))
require.False(t, strings.Contains(string(patched), "external_web_access"))
require.Equal(t, "kept_fn", gjson.GetBytes(patched, "tools.0.name").String())
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw)
}
func TestStripAnthropicThinkingSignaturesPreservesLargeIntegers(t *testing.T) {
body := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"work","signature":"opaque"}]}],"metadata":{"large_id":9007199254740993}}`)
patched, changed := stripAnthropicThinkingSignatures(body)
require.True(t, changed)
require.False(t, gjson.GetBytes(patched, "messages.0.content.0.signature").Exists())
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw)
}
func TestPatchGrokResponsesBodyFlattensNamespaceTools(t *testing.T) {
@@ -1921,6 +1933,7 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) {
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{
"model":"grok",
"previous_response_id":"resp_valid_history",
"input":[
{"type":"reasoning","summary":[{"type":"summary_text","text":"keep this summary"}],"encrypted_content":"encrypted-reasoning"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}
@@ -1973,8 +1986,10 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) {
require.Len(t, upstream.bodies, 2)
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.type").String())
require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
require.Equal(t, "encrypted-reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.encrypted_content").String())
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[1], "input.0.type").String())
require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[1], "previous_response_id").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists())
require.Equal(t, "keep this summary", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.text").String())
require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.1.type").String())
@@ -2548,7 +2563,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"stream":false}`)
body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"metadata":{"large_id":9007199254740993},"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("api_key", &APIKey{ID: 5501})
@@ -2597,6 +2612,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T)
require.NotEmpty(t, upstream.requests[1].Header.Get(grokConversationIDHeader))
require.Equal(t, "grok-composer-2.5-fast", gjson.GetBytes(upstream.bodies[1], "model").String())
require.False(t, strings.Contains(string(upstream.bodies[1]), "image_url"))
require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.bodies[1], "metadata.large_id").Raw)
require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "Image 1 description")
require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "A small diagram with ABC letters.")
require.Equal(t, 14, result.Usage.InputTokens)
@@ -113,10 +113,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
compatContinuationDisabled := compatContinuationEnabled &&
s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, promptCacheKey)
compatTurnState := ""
// OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a
// ChatGPT/Codex credentials rely on session_id + x-codex-turn-state; trimming to a
// sliding 12-message window makes the cached prefix stall at system/tools.
// Keep full replay there so upstream prompt caching can grow turn by turn.
if compatReplayGuardEnabled && account.Type != AccountTypeOAuth && previousResponseID == "" && !compatContinuationDisabled {
if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() && previousResponseID == "" && !compatContinuationDisabled {
compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq)
}
@@ -144,7 +144,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
responsesReq.PreviousResponseID = previousResponseID
trimAnthropicCompatResponsesInputToLatestTurn(responsesReq)
}
if compatReplayGuardEnabled && account.Type != AccountTypeOAuth {
if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() {
appendOpenAICompatClaudeCodeTodoGuard(responsesReq)
}
@@ -179,13 +179,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
logger.L().Debug("openai messages: model mapping applied", logFields...)
// 4. Marshal Responses request body, then apply OAuth codex transform
// 4. Marshal Responses request body, then apply the ChatGPT/Codex transform.
responsesBody, err := json.Marshal(responsesReq)
if err != nil {
return nil, fmt.Errorf("marshal responses request: %w", err)
}
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
var reqBody map[string]any
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
@@ -311,7 +311,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
// 6. Build upstream request
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
// Messages 兼容桥即使 body 未带 todo-guard/prompt_cache_key 标记(如映射到非
// gpt-5/codex 模型),也必须让 buildUpstreamRequest 走 bridge 分支,以保留
// 既有 body/session/conversation 行为。身份头在 post-build 阶段统一恢复。
@@ -338,7 +338,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
upstreamReq.Header.Set("conversation_id", isolatedSessionID)
}
}
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
// buildUpstreamRequest 保留 Messages bridge 的 body/session 兼容行为,并会先
// 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失
// originator/OpenAI-Beta 返回 404(issue #3901)。
@@ -350,7 +350,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
zap.Bool("compat_identity_restored", true),
)
}
if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
if account.UsesOpenAICodexProtocol() && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
upstreamReq.Header.Del("conversation_id")
}
if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" {
@@ -461,7 +461,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.Header, resp.StatusCode)
}
if account.Type == AccountTypeOAuth && promptCacheKey != "" {
if account.UsesOpenAICodexProtocol() && promptCacheKey != "" {
if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" {
s.bindOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey, turnState)
}
@@ -614,6 +614,9 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
return nil, fmt.Errorf("upstream response failed: %s", message)
}
if strings.TrimSpace(finalResponse.Status) == "completed" {
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false)
}
// When the terminal event has an empty output array, reconstruct from
// accumulated delta events so the client receives the full content.
@@ -652,7 +655,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
func isOpenAICompatResponsesTerminalEvent(eventType string) bool {
switch strings.TrimSpace(eventType) {
case "response.completed", "response.done", "response.incomplete", "response.failed":
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled", "error":
return true
default:
return false
@@ -694,6 +697,28 @@ type openAICompatBufferedReadError struct {
func (e *openAICompatBufferedReadError) Error() string { return e.cause.Error() }
func (e *openAICompatBufferedReadError) Unwrap() error { return e.cause }
func openAICompatTerminalResponse(event *apicompat.ResponsesStreamEvent, payload []byte) *apicompat.ResponsesResponse {
if event == nil {
return nil
}
if event.Response != nil {
return event.Response
}
switch strings.TrimSpace(event.Type) {
case "response.failed", "error":
message := extractOpenAISSEErrorMessage(payload)
if message == "" {
message = "Upstream response failed"
}
return &apicompat.ResponsesResponse{
Status: "failed",
Error: &apicompat.ResponsesError{Code: event.Code, Message: message},
}
default:
return nil
}
}
func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
resp *http.Response,
c *gin.Context,
@@ -779,18 +804,19 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal([]byte(payload), &event); err == nil {
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
acc.ProcessEvent(&event)
if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil {
if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil {
if event.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
if event.Response.Usage == nil {
event.Response.Usage = event.Usage
if response.Usage == nil {
response.Usage = event.Usage
}
}
if event.Response.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
if response.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(response.Usage)
}
return event.Response, usage, acc, nil
return response, usage, acc, nil
}
}
}
@@ -825,20 +851,21 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
)
continue
}
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
acc.ProcessEvent(&event)
if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil {
if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil {
if event.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
if event.Response.Usage == nil {
event.Response.Usage = event.Usage
if response.Usage == nil {
response.Usage = event.Usage
}
}
if event.Response.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
if response.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(response.Usage)
}
return event.Response, usage, acc, nil
return response, usage, acc, nil
}
case <-timeoutCh:
@@ -879,6 +906,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
clientOutputStarted := false
var streamFailoverErr error
var streamNonFailoverErr error
terminalEventType := ""
searchCount := 0
streamSearchSeen := make(map[string]struct{})
countSearch := account != nil && account.IsGrok()
@@ -946,11 +974,13 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
return false
}
observer.ObserveOpenAI([]byte(payload), event.Type)
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
eventType := strings.TrimSpace(event.Type)
isBareErrorEvent := eventType == "error"
isTerminalEvent := isOpenAICompatResponsesTerminalEvent(eventType) || isBareErrorEvent
if isTerminalEvent {
terminalEventType = eventType
if event.Response != nil {
if id := strings.TrimSpace(event.Response.ID); id != "" {
responseID = id
@@ -992,7 +1022,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// Once Anthropic output has started, switching accounts would splice
// two model streams together. Surface a proper Anthropic error event
// instead of returning a failover error that the handler cannot retry.
if !clientOutputStarted && openAIStreamFailedEventShouldFailover(payloadBytes, message) {
shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message)
if isBareErrorEvent {
shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message)
}
if !clientOutputStarted && shouldFailover {
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
return true
}
@@ -1082,6 +1116,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
c.Writer.Flush()
}
}
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected)
return resultWithUsage(), nil
}
@@ -155,6 +155,33 @@ func TestForwardAsAnthropic_StreamingBareErrorBeforeOutputFailsOver(t *testing.T
require.Empty(t, rec.Body.String(), "failover path must not commit downstream output")
}
func TestForwardAsAnthropic_StreamingGenericBareErrorBeforeOutputIsNotHiddenByFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := "event: error\n" +
`data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"mixed tools failed"}}` + "\n\n" +
"data: [DONE]\n\n"
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
_, err := svc.ForwardAsAnthropic(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr))
require.Contains(t, rec.Body.String(), "mixed tools failed")
}
func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -115,9 +115,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
reqStream bool,
startTime time.Time,
) (*OpenAIForwardResult, error) {
requestedModel := reqModel
upstreamPassthroughModel := ""
if isOpenAIResponsesCompactPath(c) {
compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel)
compactMappedModel := s.resolveOpenAICompactFallbackModel(account, reqModel)
if compactMappedModel != "" && compactMappedModel != reqModel {
nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
if setErr != nil {
@@ -129,7 +130,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
}
if account != nil && account.Type == AccountTypeOAuth {
if account != nil && account.UsesOpenAICodexProtocol() {
if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
@@ -182,6 +183,25 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
stageCodexFingerprintIDs(c, fpIDs)
}
}
if account != nil && account.IsOpenAI() {
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
if normalizeErr != nil {
return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
}
if normalized {
body = normalizedBody
}
if account.IsOpenAIOAuthLike() {
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(body)
if aliasErr != nil {
return nil, aliasErr
}
mergeCodexToolNameReverse(c, reverse)
if aliased {
body = aliasedBody
}
}
}
if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
!isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) {
@@ -303,8 +323,15 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
agentTaskRecoveryTried := false
compactModelFallbackRetried := false
rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
var resp *http.Response
for {
actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
if actualModel == "" {
actualModel = reqModel
}
SetOpsUpstreamModel(c, actualModel)
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
releaseUpstreamCtx()
@@ -329,6 +356,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
probeBody := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(probeBody))
if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, probeBody); retryErr != nil {
return nil, fmt.Errorf("normalize passthrough rejected Responses field retry body: %w", retryErr)
} else if changed && rejectedFieldRetryState.Allow(retryBody) {
body = retryBody
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying passthrough request after %s (account: %s)", reason, account.Name)
continue
}
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) {
agentTaskRecoveryTried = true
expectedTaskID := account.GetCredential("task_id")
@@ -337,6 +371,22 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
continue
}
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(probeBody)))
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, resp.StatusCode, upstreamMsg, probeBody, compactModelFallbackRetried,
); retry {
fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
body = retryBody
upstreamPassthroughModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
account.Name, fromModel, fallbackModel, extractUpstreamErrorCode(probeBody),
)
continue
}
// 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时
// 5xx 应先触发多账号 failover,且此时尚未写入下游响应。
@@ -372,6 +422,17 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
if reqStream {
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
if err != nil {
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
); retry {
body = retryBody
upstreamPassthroughModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
return s.forwardOpenAIPassthrough(ctx, c, account, body, canonicalImageIntentBody, requestedModel, true, reasoningEffort, reqStream, startTime)
}
}
return nil, err
}
usage = result.usage
@@ -380,8 +441,19 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
imageCount = result.imageCount
imageOutputSizes = result.imageOutputSizes
} else {
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, account, reqModel, upstreamPassthroughModel)
if err != nil {
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
); retry {
body = retryBody
upstreamPassthroughModel = fallbackModel
compactModelFallbackRetried = true
SetOpsUpstreamModel(c, fallbackModel)
return s.forwardOpenAIPassthrough(ctx, c, account, body, canonicalImageIntentBody, requestedModel, true, reasoningEffort, reqStream, startTime)
}
}
return nil, err
}
usage = result.usage
@@ -469,6 +541,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
switch account.Type {
case AccountTypeOAuth:
targetURL = chatgptCodexURL
case AccountTypeSetupToken:
if account.IsOpenAIOAuthLike() {
targetURL = chatgptCodexURL
}
case AccountTypeAPIKey:
baseURL := account.GetOpenAIBaseURL()
if baseURL != "" {
@@ -523,7 +599,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
}
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
// Current Codex OAuth HTTP no longer negotiates the legacy Responses
// experiment. Passthrough may receive it from an older client, so remove
// only that token while preserving any independent beta negotiation.
@@ -585,7 +661,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
applyStagedCodexFingerprintHeaders(c, account, req.Header)
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
if account.Type == AccountTypeOAuth {
if account.UsesOpenAICodexProtocol() {
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
@@ -636,9 +712,15 @@ func stripOpenAILegacyResponsesBeta(headers http.Header) {
}
func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
if hit, _, _ := detectOpenAICyberPolicy(responseBody); hit {
return false
}
if isOpenAIContextWindowError("", responseBody) {
return false
}
if isOpenAIUpstreamAccessStateError("", responseBody) {
return true
}
if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
return true
}
@@ -774,11 +856,13 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
Detail: upstreamDetail,
UpstreamResponseBody: upstreamDetail,
})
return newOpenAIUpstreamFailoverError(
return s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
resp.Header,
body,
upstreamMsg,
shouldDisable,
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
)
}
@@ -1197,6 +1281,12 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
}
combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized ||
status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 {
return status
}
}
switch {
case strings.Contains(combined, "rate_limit"):
return http.StatusTooManyRequests
@@ -1206,6 +1296,8 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
return http.StatusUnauthorized
case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
return http.StatusForbidden
case isOpenAIUpstreamAccessStateError(message, payload):
return http.StatusForbidden
case isOpenAIUpstreamCapacityShedEvent(payload):
return http.StatusServiceUnavailable
default:
@@ -1217,15 +1309,52 @@ func openAIStreamFailureStatus(payload []byte, message string) int {
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
return http.StatusBadGateway
}
// Keep the existing 502 failover behavior for other response.failed events.
// Only rate limits need promotion because they participate in the account's
// configurable 429 same-account retry policy.
if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests {
return http.StatusTooManyRequests
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
switch semanticStatus {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests, 529:
return semanticStatus
case http.StatusServiceUnavailable:
if isOpenAIUpstreamCapacityShedEvent(payload) {
return semanticStatus
}
}
return http.StatusBadGateway
}
// openAIStreamCredentialAuthFailure distinguishes credential failures from
// request/content permission denials carried inside an HTTP 200 stream. Do not
// infer credential health from free-form 403 messages: providers also use
// permission_error/forbidden/access denied for request-scoped policy failures.
func openAIStreamCredentialAuthFailure(payload []byte) bool {
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
return false
}
for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized {
return true
}
}
for _, path := range []string{"response.error.type", "error.type", "type"} {
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String()))
if errType == "authentication_error" || errType == "authentication_failed" || errType == "unauthorized_error" {
return true
}
}
for _, path := range []string{"response.error.code", "error.code", "code"} {
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) {
case "invalid_api_key", "api_key_disabled", "unauthorized", "authentication_error",
"invalid_token", "access_token_invalid", "token_revoked", "token_invalidated",
"invalid_credentials", "credential_invalid":
return true
}
}
return false
}
func openAIStream403AccountFailure(payload []byte, message string) bool {
return isOpenAIUpstreamAccessStateError(message, payload) || openAIStreamCredentialAuthFailure(payload)
}
func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return payload
@@ -1299,13 +1428,23 @@ func applyOpenAIStreamFailedErrorPassthroughRule(
}
func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool {
if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
return false
}
if isOpenAIContextWindowError(message, payload) {
return false
}
if isOpenAIUpstreamAccessStateError(message, payload) {
return true
}
semanticStatus := openAIStreamFailureStatus(payload, message)
if semanticStatus == http.StatusForbidden {
return openAIStream403AccountFailure(payload, message)
}
// A response.failed event is transported over HTTP 200. Prefer its semantic
// rate-limit status over a generic/invalid_request error type so it can enter
// the same 429 retry policy as a regular upstream HTTP response.
if openAIStreamFailureStatus(payload, message) == http.StatusTooManyRequests {
if semanticStatus == http.StatusTooManyRequests {
return true
}
if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
@@ -1341,15 +1480,49 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
}
func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool {
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" {
if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
return false
}
if isOpenAIContextWindowError(message, payload) {
return false
}
if isOpenAIUpstreamAccessStateError(message, payload) {
return true
}
switch openAIStreamFailedEventSemanticStatus(payload, message) {
case http.StatusForbidden:
return openAIStream403AccountFailure(payload, message)
case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
return true
}
return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
}
func (s *OpenAIGatewayService) handleOpenAIStreamTerminalAccountSideEffects(
c *gin.Context,
account *Account,
payload []byte,
message string,
headers http.Header,
) (int, bool) {
statusCode := openAIStreamFailureStatus(payload, message)
switch statusCode {
case http.StatusForbidden:
if !openAIStream403AccountFailure(payload, message) {
return statusCode, false
}
fallthrough
case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
var ctx context.Context = context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
return statusCode, s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, payload)
default:
return statusCode, false
}
}
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
if account == nil {
return false
@@ -1425,14 +1598,13 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
if message == "" {
message = "OpenAI stream disconnected before completion"
}
statusCode := openAIStreamFailureStatus(payload, message)
var headers http.Header
if len(responseHeaders) > 0 && responseHeaders[0] != nil {
headers = responseHeaders[0].Clone()
}
// 流内 failed 事件承载于 HTTP 200,响应头是正常配额快照而非限流信号,
// 不写账号级限流/封禁状态;重试与切号由 failover 引擎按
// StatusCode/RetryableOnSameAccount 决定。
statusCode, shouldDisable := s.handleOpenAIStreamTerminalAccountSideEffects(c, account, payload, message, headers)
// 流内 failed 事件承载于 HTTP 200;使用事件的语义状态更新账号健康,
// 再由 failover 引擎按 StatusCode/RetryableOnSameAccount 决定恢复策略。
message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
errType := "upstream_error"
if statusCode == http.StatusTooManyRequests {
@@ -1444,13 +1616,15 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
"message": message,
},
})
return &UpstreamFailoverError{
StatusCode: statusCode,
ResponseBody: body,
ResponseHeaders: headers,
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload),
retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message)
failoverErr := s.newOpenAIAccountFailoverError(account, statusCode, headers, payload, message, shouldDisable, retryableOnSameAccount)
if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient {
return failoverErr
}
// Preserve the existing generic envelope for unclassified stream failures;
// only typed access/capacity failures need the original payload downstream.
failoverErr.ResponseBody = body
return failoverErr
}
func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
@@ -1491,15 +1665,25 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
sawDone := false
sawTerminalEvent := false
sawFailedEvent := false
sawBareError := false
sawResponseFailed := false
terminalEventType := ""
semanticOutputSeen := false
capacityFailoverSuppressedLogged := false
failedMessage := ""
clientOutputStarted := false
codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
failureDelivered := false
suppressCurrentEvent := false
responseFailedPending := false
var bareErrorPayload []byte
bareErrorAccountSideEffectsPending := false
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
// pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
pendingLines := make([]string, 0, 8)
// flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
flushPending := false
pendingSSEEventType := ""
flushPendingOutput := func() {
if clientDisconnected || !flushPending {
return
@@ -1519,6 +1703,26 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
pendingLines = pendingLines[:0]
return true
}
ensureResponseFailedTerminal := func() {
if !sawBareError || sawResponseFailed || failureDelivered {
return
}
if bareErrorAccountSideEffectsPending {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
if clientDisconnected || !writePendingLines() {
return
}
if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
clientDisconnected = true
return
}
clientOutputStarted = true
failureDelivered = true
flushPending = true
flushPendingOutput()
}
scanner := bufio.NewScanner(resp.Body)
maxLineSize := defaultMaxLineSize
@@ -1543,12 +1747,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
for documentScanner.Scan() {
line := documentScanner.Text()
if eventType, ok := extractOpenAISSEEventLine(line); ok {
pendingSSEEventType = eventType
eventType = strings.TrimSpace(eventType)
suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
}
lineStartsClientOutput := false
forceFlushFailedEvent := false
if data, ok := extractOpenAISSEDataLine(line); ok {
dataBytes := []byte(data)
trimmedData := strings.TrimSpace(data)
rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
observer.ObserveOpenAI(dataBytes, rawEventType)
if needModelReplace && strings.Contains(data, mappedModel) {
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
@@ -1572,14 +1781,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if restoreErr != nil {
return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
}
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, rawEventType)
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
trimmedData = strings.TrimSpace(string(restoredData))
line = "data: " + string(restoredData)
}
}
eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String())
eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType)
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
suppressCurrentEvent = true
}
if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
(eventType == "error" || eventType == "response.failed") &&
openAIStreamClientOutputStarted(c, clientOutputStarted) &&
@@ -1587,32 +1799,26 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType)
capacityFailoverSuppressedLogged = true
}
if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) {
errorMessage := extractOpenAISSEErrorMessage(dataBytes)
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched {
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg)
cyberHit := false
if eventType == "response.failed" || eventType == "error" {
if codexFailureTerminal && eventType == "error" {
sawBareError = true
bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
suppressCurrentEvent = true
} else if codexFailureTerminal && eventType == "response.failed" {
sawResponseFailed = true
}
if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) {
return resultWithUsage(),
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header)
}
}
if eventType == "response.failed" {
responseFailedPending = !codexFailureTerminal || eventType == "response.failed"
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
if failedMessage == "" {
failedMessage = "Upstream response failed"
}
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
s.parseSSEUsageBytes(dataBytes, usage)
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
cyberHit = true
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
@@ -1622,34 +1828,64 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
UpstreamOutTok: usage.OutputTokens,
})
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
if !outputStarted && !cyberHit {
if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
return resultWithUsage(), compactErr
}
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
}
if outputStarted && !cyberHit {
if codexFailureTerminal && eventType == "error" {
// Wait for the authoritative response.failed before mutating
// account health; EOF synthesis applies the pending effect.
bareErrorAccountSideEffectsPending = true
} else {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
}
if !outputStarted {
shouldFailover := false
if !cyberHit {
if eventType == "error" {
shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
} else {
shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
}
}
if shouldFailover {
return resultWithUsage(),
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header)
}
if !cyberHit && !sawBareError {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
}
}
}
forceFlushFailedEvent = true
sawFailedEvent = true
}
if trimmedData == "[DONE]" {
sawDone = true
terminalEventType = "[DONE]"
}
if openAIStreamEventIsTerminal(trimmedData) {
if openAIStreamEventIsTerminalWithType(trimmedData, eventType) {
sawTerminalEvent = true
if trimmedData != "[DONE]" {
terminalEventType = eventType
}
}
if responseID == "" {
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
@@ -1681,10 +1917,18 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
s.parseSSEUsageBytes(dataBytes, usage)
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
}
if line == "" {
pendingSSEEventType = ""
if suppressCurrentEvent {
suppressCurrentEvent = false
responseFailedPending = false
continue
}
}
if !clientDisconnected {
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
if !clientOutputStarted && !lineStartsClientOutput {
pendingLines = append(pendingLines, line)
continue
@@ -1705,7 +1949,12 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
}
}
}
if line == "" && responseFailedPending {
responseFailedPending = false
failureDelivered = true
}
}
ensureResponseFailedTerminal()
if err := documentScanner.Err(); err != nil {
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
s.clearOpenAIProxyStreamDisconnect(account)
@@ -1760,6 +2009,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
s.clearOpenAIProxyStreamDisconnect(account)
}
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
return resultWithUsage(), nil
}
@@ -1768,6 +2018,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
originalModel string,
mappedModel string,
) (*openaiNonStreamingResultPassthrough, error) {
@@ -1790,7 +2041,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
// stream=false was requested. Without this conversion the client would
// receive raw SSE text or a terminal event with empty output.
if isEventStreamResponse(resp.Header) {
return s.handlePassthroughSSEToJSON(resp, c, body, originalModel, mappedModel)
return s.handlePassthroughSSEToJSON(resp, c, account, body, originalModel, mappedModel)
}
usage := &OpenAIUsage{}
@@ -1805,6 +2056,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
// 兜底:尝试从 SSE 文本中解析 usage
usage = s.parseSSEUsageFromBody(string(body))
}
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
@@ -1842,11 +2094,22 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
// response for the passthrough path. It mirrors handleSSEToJSON while
// preserving passthrough payloads, except compact-only model remapping may
// rewrite model fields back to the original requested model.
func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
bodyText := string(body)
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
msg := extractOpenAISSEErrorMessage(terminalPayload)
if msg == "" {
msg = "Upstream compact response failed"
}
if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
return nil, compactErr
}
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
}
finalResponse, ok := extractCodexFinalResponse(bodyText)
usage := &OpenAIUsage{}
usage := s.parseSSEUsageFromBody(bodyText)
if ok {
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
*usage = parsedUsage
@@ -1874,15 +2137,6 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && terminalType == "response.failed" {
msg := extractOpenAISSEErrorMessage(terminalPayload)
if msg == "" {
msg = "Upstream compact response failed"
}
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
}
usage = s.parseSSEUsageFromBody(bodyText)
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
}
@@ -1890,6 +2144,7 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
}
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
contentType := "application/json; charset=utf-8"
if !ok {
@@ -191,6 +191,48 @@ func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBounda
require.Zero(t, result.usage.OutputTokens)
}
func TestOpenAIStreamingPassthroughBareErrorTerminatesBeforeDone(t *testing.T) {
errorEvent := "event: error\n" +
`data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n"
upstream := errorEvent + "data: [DONE]\n\n"
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr))
require.NotNil(t, result)
body := recorder.Body.String()
require.NotContains(t, body, `"type":"error"`)
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
require.Contains(t, body, `"status":"failed"`)
require.NotContains(t, body, "[DONE]")
require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
require.Equal(t, 6, result.usage.InputTokens)
require.Zero(t, result.usage.OutputTokens)
}
func TestOpenAIStreamingPassthroughBareErrorDrainsAuthoritativeFailedUsage(t *testing.T) {
errorEvent := "event: error\n" +
`data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"}}` + "\n\n"
failedEvent := "event: response.failed\n" +
`data: {"type":"response.failed","response":{"id":"resp_failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"code":"content_policy","message":"request blocked by policy"}}}` + "\n\n"
upstream := errorEvent + failedEvent + "data: [DONE]\n\n"
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
require.Error(t, err)
require.NotNil(t, result)
body := recorder.Body.String()
require.NotContains(t, body, `"type":"error"`)
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
require.Contains(t, body, `"id":"resp_failed"`)
require.NotContains(t, body, "[DONE]")
require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
require.Equal(t, 9, result.usage.InputTokens)
require.Equal(t, 2, result.usage.OutputTokens)
}
func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) {
firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
failedEvent := "event: response.failed\n" +
@@ -213,9 +213,7 @@ func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, ch
return body, false, nil
}
var decoded map[string]any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &decoded); err != nil {
return body, false, fmt.Errorf("decode cross-mode failover body: %w", err)
}
if !dropOpenAIEncryptedReasoningInputItems(decoded) {
@@ -326,7 +324,6 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
normalized := []byte(`{}`)
// Keep the current Codex /compact schema while still dropping request-scoped
// fields such as prompt_cache_key, store, and stream.
@@ -351,6 +348,11 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
}
normalized = next
}
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
return body, false, err
} else if removed {
normalized = next
}
if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) {
return body, false, nil
@@ -358,8 +360,91 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
return normalized, true, nil
}
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) {
parallel := gjson.GetBytes(body, "parallel_tool_calls")
if !parallel.Exists() {
return body, false, nil
}
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() && len(tools.Array()) > 0 {
return body, false, nil
}
normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
if err != nil {
return body, false, fmt.Errorf("normalize parallel_tool_calls without tools: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, false, nil
}
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", err)
}
items, ok := reqBody["input"].([]any)
if !ok {
return body, false, nil
}
filtered := make([]any, 0, len(items))
changed := false
for _, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
filtered = append(filtered, rawItem)
continue
}
typ := strings.TrimSpace(firstNonEmptyString(item["type"]))
id := strings.TrimSpace(firstNonEmptyString(item["id"]))
switch typ {
case "reasoning":
encryptedContent, hasEncryptedContent := item["encrypted_content"].(string)
if !hasEncryptedContent || strings.TrimSpace(encryptedContent) == "" {
changed = true
continue
}
if strings.HasPrefix(id, "rs_") {
delete(item, "id")
changed = true
}
if summary, ok := item["summary"]; !ok || summary == nil {
item["summary"] = []any{}
changed = true
}
case "item_reference":
if strings.HasPrefix(id, "rs_") {
changed = true
continue
}
}
if shouldStripOpenAIResponsesNonPairCallID(typ) {
if _, hasCallID := item["call_id"]; hasCallID {
delete(item, "call_id")
changed = true
}
}
filtered = append(filtered, item)
}
if !changed {
return body, false, nil
}
reqBody["input"] = filtered
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize API-key store=false reasoning replay: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAICodexCompactReasoningEffortForAccount(c *gin.Context, account *Account, body []byte) ([]byte, bool, error) {
if account == nil || !account.IsOpenAIOAuth() || !isOpenAIResponsesCompactPath(c) {
if account == nil || !account.IsOpenAIOAuthLike() || !isOpenAIResponsesCompactPath(c) {
return body, false, nil
}
@@ -825,9 +910,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
return body, false, nil
}
var reqBody map[string]any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&reqBody); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize responses schema body: %w", err)
}
if !normalizeOpenAIResponseFormatSchemas(reqBody) {
@@ -841,8 +924,32 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
}
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) {
if account == nil || !account.IsOpenAI() {
return body, false, nil
}
normalized := body
changed := false
if account.IsOpenAIOAuthLike() {
var err error
normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body)
if err != nil {
return body, false, err
}
}
if account.IsOpenAIApiKey() {
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
return body, false, err
} else if normalizedParallel {
normalized = next
changed = true
}
if next, normalizedReasoning, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(normalized, false); err != nil {
return body, false, err
} else if normalizedReasoning {
normalized = next
changed = true
}
}
if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil {
return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err)
} else if idsChanged {
@@ -857,7 +964,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
changed = true
}
}
if account != nil && account.IsOpenAIOAuth() {
if account != nil && account.IsOpenAIOAuthLike() {
oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized)
if err != nil {
return body, false, err
@@ -876,6 +983,35 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
changed = true
}
}
needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() &&
gjson.GetBytes(normalized, "input").IsArray()
if needsOrphanCleanup || openAIResponsesInputMayNeedTruncation(normalized) {
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(normalized, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize websocket Responses body: %w", err)
}
mapChanged := false
if needsOrphanCleanup {
if input, ok := reqBody["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
reqBody,
input,
strings.TrimSpace(firstNonEmptyString(reqBody["previous_response_id"])) != "",
) {
mapChanged = true
}
}
if truncateOpenAIResponsesInputText(reqBody) {
mapChanged = true
}
if mapChanged {
next, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized websocket Responses body: %w", err)
}
normalized = next
changed = true
}
}
if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil {
return body, false, err
} else if schemaChanged {
@@ -896,16 +1032,26 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
changed = true
}
}
if toolBody, toolChanged, err := sanitizeOpenAIResponsesToolParameterTypes(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool parameter types: %w", err)
} else if toolChanged {
normalized = toolBody
changed = true
if account != nil && shouldSanitizeOpenAIResponsesToolSchemas(account.Platform) {
if toolBody, toolChanged, err := sanitizeOpenAIResponsesToolParameterTypes(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool parameter types: %w", err)
} else if toolChanged {
normalized = toolBody
changed = true
}
if patternBody, patternChanged, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool schema patterns: %w", err)
} else if patternChanged {
normalized = patternBody
changed = true
}
}
if patternBody, patternChanged, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket tool schema patterns: %w", err)
} else if patternChanged {
normalized = patternBody
// Keep this last: earlier compatibility passes may filter or rebuild input.
// Remote compaction v2 requires one trigger as the final input item.
if triggerBody, triggerChanged, err := NormalizeCompactionTriggerInputOrder(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket compaction trigger order: %w", err)
} else if triggerChanged {
normalized = triggerBody
changed = true
}
return normalized, changed, nil
@@ -1657,7 +1803,7 @@ func isEmptyBase64DataURI(raw string) bool {
func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) {
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return nil, fmt.Errorf("parse request: %w", err)
}
return reqBody, nil
@@ -199,3 +199,74 @@ func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testi
_, hasInput := reqBody["input"]
assert.False(t, hasInput, "bare reasoning skeleton should be dropped, emptying input")
}
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplay(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","store":false,"input":[` +
`{"type":"reasoning","id":"rs_encrypted","call_id":"remove","encrypted_content":"cipher","summary":null,"opaque":9007199254740993},` +
`{"type":"reasoning","id":"rs_server_only","summary":[{"type":"summary_text","text":"drop"}]},` +
`{"type":"item_reference","id":"rs_server_only"},` +
`{"type":"item_reference","id":"msg_keep"},` +
`{"type":"message","id":"msg_keep","role":"user","content":"continue"}` +
`]}`)
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, int64(3), gjson.GetBytes(normalized, "input.#").Int())
require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.0.type").String())
require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
require.Equal(t, "cipher", gjson.GetBytes(normalized, "input.0.encrypted_content").String())
require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.0.opaque").Raw)
require.Equal(t, "msg_keep", gjson.GetBytes(normalized, "input.1.id").String())
require.Equal(t, "message", gjson.GetBytes(normalized, "input.2.type").String())
}
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRequiresExplicitStoreFalse(t *testing.T) {
for _, body := range []string{
`{"input":[{"type":"reasoning","id":"rs_keep"}]}`,
`{"store":true,"input":[{"type":"reasoning","id":"rs_keep"}]}`,
} {
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay([]byte(body), false)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, string(normalized))
}
}
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayKnownCompactMode(t *testing.T) {
body := []byte(`{"input":[{"type":"reasoning","id":"rs_drop","summary":[]},{"type":"message","content":"continue"}]}`)
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, true)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
}
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedContent(t *testing.T) {
for _, encrypted := range []string{"null", `""`, `" "`, "123"} {
body := []byte(`{"store":false,"input":[{"type":"reasoning","id":"rs_drop","encrypted_content":` + encrypted + `},{"type":"message","content":"continue"}]}`)
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
}
}
func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(withTools), string(normalized))
withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`)
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
}
@@ -5,13 +5,16 @@ package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -84,6 +87,101 @@ func TestForwardAsChatCompletions_ResponseFailed_PassthroughRule(t *testing.T) {
require.Contains(t, errMsg, "context window")
}
func TestResponsesStreamAccessStateFailoverPrecedesPassthroughRule(t *testing.T) {
gin.SetMode(gin.TestMode)
stream := "event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"account_disabled","message":"Your account is disabled"}}}` + "\n\n"
tests := []struct {
name string
run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
}{
{
name: "native",
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
return err
},
},
{
name: "passthrough",
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
_, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
bindPassthroughRule(c, PlatformOpenAI, []string{"account is disabled"}, http.StatusTeapot)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(stream)),
}
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
err := tt.run(svc, c, resp, &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.IsCredentialFailure())
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
require.False(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
require.False(t, c.Writer.Written(), "passthrough rule must not commit a response before account failover")
})
}
}
func TestResponsesStreamCyberPolicyPrecedesPassthroughRule(t *testing.T) {
gin.SetMode(gin.TestMode)
stream := "event: error\n" +
`data: {"type":"error","error":{"code":"cyber_policy","message":"blocked by cyber policy"}}` + "\n\n"
tests := []struct {
name string
run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
}{
{
name: "native",
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
return err
},
},
{
name: "passthrough",
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
_, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
bindPassthroughRule(c, PlatformOpenAI, []string{"cyber policy"}, http.StatusTeapot)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(stream)),
}
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
err := tt.run(svc, c, resp, &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr))
require.NotNil(t, GetOpsCyberPolicy(c))
require.NotEqual(t, http.StatusTeapot, rec.Code)
require.Contains(t, rec.Body.String(), "cyber_policy")
})
}
}
func TestForwardAsAnthropic_ResponseFailed_PassthroughRule(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -387,20 +387,41 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T)
require.Contains(t, flushes[1], "response.failed")
})
t.Run("retryable error event buffered until terminal", func(t *testing.T) {
// 可重试类 error 帧不算客户端输出:保持在 attempt 缓冲中不单独 flush,
// 为随后可能到达的 response.failed 保留 pre-output failover 能力,
// 与终止帧一起出站。
t.Run("bare error synthesizes failed before done", func(t *testing.T) {
body := "data: {\"type\":\"error\",\"error\":{\"message\":\"failed\"}}\n\n" +
"data: [DONE]\n\n"
recorder := newOpenAIResponseFlushRecorder()
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
require.NoError(t, err)
require.Error(t, err)
require.NotNil(t, result)
gotBody, flushes := recorder.snapshot()
require.Equal(t, body, gotBody)
require.NotContains(t, gotBody, `"type":"error"`)
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
require.Contains(t, gotBody, `"status":"failed"`)
require.NotContains(t, gotBody, "[DONE]")
require.Len(t, flushes, 1)
})
t.Run("bare error forwards following failed and drains terminal usage", func(t *testing.T) {
errorEvent := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n"
body := errorEvent +
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"usage\":{\"input_tokens\":9,\"output_tokens\":2},\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}}\n\n" +
"data: [DONE]\n\n"
recorder := newOpenAIResponseFlushRecorder()
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
require.Error(t, err)
require.NotNil(t, result)
require.Equal(t, 9, result.usage.InputTokens)
require.Equal(t, 2, result.usage.OutputTokens)
gotBody, flushes := recorder.snapshot()
require.NotContains(t, gotBody, `"type":"error"`)
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
require.Contains(t, gotBody, `"id":"resp_failed"`)
require.NotContains(t, gotBody, "[DONE]")
require.Len(t, flushes, 1)
})
@@ -411,14 +432,56 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T)
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
require.NoError(t, err)
require.Error(t, err)
require.NotNil(t, result)
gotBody, flushes := recorder.snapshot()
require.Equal(t, body, gotBody)
require.Len(t, flushes, 2)
require.NotContains(t, gotBody, `"type":"error"`)
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
require.Len(t, flushes, 1)
})
}
func TestOpenAIResponseFlush_BareErrorTimeoutSynthesizesFailed(t *testing.T) {
tests := []struct {
name string
cfg config.GatewayConfig
}{
{
name: "stream interval timeout",
cfg: config.GatewayConfig{StreamDataIntervalTimeout: 1},
},
{
name: "first output timeout",
cfg: config.GatewayConfig{OpenAIFirstOutputTimeoutSeconds: 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader, writer := io.Pipe()
defer writer.Close()
recorder := newOpenAIResponseFlushRecorder()
resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, tt.cfg)
_, writeErr := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n")
require.NoError(t, writeErr)
select {
case err := <-errCh:
require.Error(t, err)
require.Contains(t, err.Error(), "upstream response failed")
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for bare error terminal synthesis")
}
require.NotNil(t, <-resultCh)
body, flushes := recorder.snapshot()
require.NotContains(t, body, `"type":"error"`)
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
require.Len(t, flushes, 1)
})
}
}
func TestOpenAIResponseFlush_ReusedTypeKeepsSSEBytesAndTerminalSemantics(t *testing.T) {
tests := []struct {
name string
@@ -17,8 +17,10 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
// openaiStreamingResult streaming response result
@@ -241,12 +243,22 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage
sawTerminalEvent := false
sawFailedEvent := false
sawBareError := false
sawResponseFailed := false
terminalEventType := ""
responsesSemanticOutputSeen := false
capacityFailoverSuppressedLogged := false
failedMessage := ""
clientOutputStarted := false
codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
var streamEarlyErr error
terminalFailurePending := false
failureDelivered := false
suppressCurrentEvent := false
var bareErrorPayload []byte
bareErrorAccountSideEffectsPending := false
pendingSSEEventType := ""
eventInProgress := false
eventStartsClientOutput := false
eventStartsVisibleOutput := false
@@ -300,7 +312,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
eventShouldFlush = false
}
sendErrorEvent := func(reason string) {
if errorEventSent || clientDisconnected {
if errorEventSent || clientDisconnected || failureDelivered {
return
}
errorEventSent = true
@@ -356,6 +368,18 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
// EOF dispatches the final SSE event even without a trailing blank line.
completeGuardedEvent(true)
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && bareErrorAccountSideEffectsPending {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
if codexFailureTerminal && sawBareError && !sawResponseFailed && !clientDisconnected {
applyAttemptResponseHeaders()
if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
handlePendingWriteError(err)
} else {
failureDelivered = true
}
}
if sawTerminalEvent && !sawFailedEvent {
s.clearOpenAIProxyStreamDisconnect(account)
}
@@ -379,6 +403,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
if sawFailedEvent {
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
return resultWithUsage(), nil
}
handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) {
@@ -443,15 +468,26 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
if streamEarlyErr != nil {
return
}
if eventType, ok := extractOpenAISSEEventLine(line); ok {
pendingSSEEventType = eventType
eventType = strings.TrimSpace(eventType)
suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
}
// Extract data from SSE line (supports both "data: " and "data:" formats)
if data, ok := extractOpenAISSEDataLine(line); ok {
dataBytes := []byte(data)
eventTypeRaw := gjson.GetBytes(dataBytes, "type").String()
eventType := strings.TrimSpace(eventTypeRaw)
observer.ObserveOpenAI(dataBytes, eventTypeRaw)
eventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
suppressCurrentEvent = true
}
observer.ObserveOpenAI(dataBytes, eventType)
// 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。
if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) {
if openAIStreamEventIsTerminalWithType(data, eventType) {
sawTerminalEvent = true
terminalEventType = eventType
if strings.TrimSpace(data) == "[DONE]" {
terminalEventType = "[DONE]"
}
}
if responseID == "" {
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
@@ -464,33 +500,25 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
logOpenAICapacityFailoverSuppressed(ctx, account, "native_sse", upstreamRequestID, eventType)
capacityFailoverSuppressedLogged = true
}
if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) {
errorMessage := extractOpenAISSEErrorMessage(dataBytes)
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched {
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, errorMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
streamEarlyErr = fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg)
return
cyberHit := false
if eventType == "response.failed" || eventType == "error" {
if codexFailureTerminal && eventType == "error" {
sawBareError = true
bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
suppressCurrentEvent = true
} else if codexFailureTerminal && eventType == "response.failed" {
sawResponseFailed = true
}
if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) {
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, errorMessage, resp.Header)
return
}
}
if eventType == "response.failed" {
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
if failedMessage == "" {
failedMessage = "Upstream response failed"
}
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
s.parseSSEUsageBytes(dataBytes, usage)
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
cyberHit = true
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
@@ -500,31 +528,60 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
UpstreamOutTok: usage.OutputTokens,
})
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
if !outputStarted && !cyberHit {
if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
sawFailedEvent = true
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
streamEarlyErr = compactErr
return
}
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
}
if outputStarted && !cyberHit {
if codexFailureTerminal && eventType == "error" {
// OpenAI commonly follows a bare error with response.failed.
// Defer account health updates so the pair is applied once.
bareErrorAccountSideEffectsPending = true
} else {
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header)
bareErrorAccountSideEffectsPending = false
}
}
if !outputStarted {
shouldFailover := false
if !cyberHit {
if eventType == "error" {
shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
} else {
shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
}
}
if shouldFailover {
sawFailedEvent = true
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header)
return
}
if !cyberHit && !sawBareError {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
sawFailedEvent = true
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
"error": gin.H{
"type": errType,
"message": errMsg,
},
})
streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
return
}
}
}
forceFlushFailedEvent = true
sawFailedEvent = true
terminalFailurePending = !codexFailureTerminal || eventType == "response.failed"
}
if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
dataBytes = normalizedData
@@ -539,7 +596,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
dataBytes = correctedData
data = string(correctedData)
line = "data: " + data
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok {
streamImageOutputs = append(streamImageOutputs, imageOutput)
@@ -554,7 +611,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
dataBytes = normalizedData
data = string(normalizedData)
line = "data: " + data
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
restoredData, restoreErr := restoreGrokResponsesClientToolPayload(c, dataBytes)
if restoreErr != nil {
@@ -566,12 +623,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
return
}
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, eventType)
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
data = string(restoredData)
line = "data: " + data
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
}
if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
dataBytes,
@@ -613,7 +670,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
// 写入客户端(客户端断开后继续 drain 上游)
if !clientDisconnected {
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput)
if firstTokenMs == nil && startsVisibleOutput {
// 保证首个 token 事件尽快出站,避免影响 TTFT。
@@ -635,12 +692,30 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
firstTokenMs = &ms
stopFirstOutputTimer()
}
s.parseSSEUsageBytes(dataBytes, usage)
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
return
}
// A blank line dispatches a guarded event from the attempt-local stage.
if stageFirstOutput && line == "" {
pendingSSEEventType = ""
if suppressCurrentEvent {
suppressCurrentEvent = false
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
eventStartsVisibleOutput = false
eventShouldFlush = false
return
}
if failureDelivered {
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
eventStartsVisibleOutput = false
eventShouldFlush = false
return
}
if !clientDisconnected {
if _, err := writePendingString("\n"); err != nil {
handlePendingWriteError(err)
@@ -649,16 +724,31 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
if streamEarlyErr == nil {
completeGuardedEvent(queueDrained)
}
if terminalFailurePending && streamEarlyErr == nil {
terminalFailurePending = false
failureDelivered = true
}
return
}
// Non-guarded streams retain upstream's event-boundary flushing: a keepalive
// or queue-drain flush must never split an open SSE event.
shouldFlush := false
if line == "" {
pendingSSEEventType = ""
if suppressCurrentEvent {
suppressCurrentEvent = false
terminalFailurePending = false
eventInProgress = false
eventShouldFlush = false
return
}
shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted)
eventShouldFlush = false
if failureDelivered {
terminalFailurePending = false
}
}
if !clientDisconnected {
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
if _, err := writePendingString(line); err != nil {
handlePendingWriteError(err)
} else if _, err := writePendingString("\n"); err != nil {
@@ -675,6 +765,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
}
}
if line == "" && terminalFailurePending && streamEarlyErr == nil {
terminalFailurePending = false
failureDelivered = true
}
}
}
@@ -767,6 +861,13 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
case <-intervalCh:
if codexFailureTerminal && sawBareError && !sawResponseFailed {
_ = resp.Body.Close()
return finalizeStream()
}
if failureDelivered {
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) < streamInterval {
continue
@@ -797,6 +898,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
stopFirstOutputTimer()
continue
}
if codexFailureTerminal && sawBareError && !sawResponseFailed {
_ = resp.Body.Close()
return finalizeStream()
}
_ = resp.Body.Close()
for ev := range events {
markEventProcessed(ev)
@@ -807,7 +912,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
)
case <-keepaliveCh:
if clientDisconnected {
if clientDisconnected || failureDelivered {
continue
}
if eventInProgress {
@@ -922,7 +1027,7 @@ func openAICompatPayloadWithEventType(payload, eventType string) string {
if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" {
return payload
}
if gjson.Get(payload, "type").Exists() {
if strings.TrimSpace(gjson.Get(payload, "type").String()) != "" {
return payload
}
patched, err := sjson.Set(payload, "type", eventType)
@@ -932,6 +1037,13 @@ func openAICompatPayloadWithEventType(payload, eventType string) string {
return patched
}
func effectiveOpenAISSEEventType(payload []byte, eventType string) string {
if payloadType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); payloadType != "" {
return payloadType
}
return strings.TrimSpace(eventType)
}
func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string {
data, ok := extractOpenAISSEDataLine(line)
if !ok {
@@ -1061,6 +1173,10 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
}
func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) {
s.parseSSEUsageBytesWithType(data, "", usage)
}
func (s *OpenAIGatewayService) parseSSEUsageBytesWithType(data []byte, eventType string, usage *OpenAIUsage) {
if usage == nil || len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) {
return
}
@@ -1068,7 +1184,10 @@ func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsag
if !ok {
return
}
if openAIStreamEventTypeIsTerminal(strings.TrimSpace(gjson.GetBytes(data, "type").String())) {
if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(data, eventType)) {
if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) {
return
}
*usage = parsedUsage
return
}
@@ -1076,7 +1195,9 @@ func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsag
}
// Compatible Responses upstreams may report usage before the terminal event.
// Retain those non-zero fields as a fallback; terminal usage remains authoritative.
// Retain those non-zero fields as a fallback. A terminal usage with any billed
// tokens is authoritative as a whole; an all-zero terminal does not erase a
// non-zero progressive observation from the same turn.
func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
if dst == nil {
return
@@ -1101,6 +1222,73 @@ func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
}
}
func openAIUsageHasTokens(usage *OpenAIUsage) bool {
return usage != nil && (usage.InputTokens > 0 || usage.ImageInputTokens > 0 ||
usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 ||
usage.CacheReadInputTokens > 0 || usage.ImageOutputTokens > 0)
}
const openAIMissingUsageLogInterval = time.Minute
type openAIMissingUsageLogSampler struct {
total atomic.Uint64
suppressed atomic.Uint64
lastLog atomic.Int64
}
var openAIMissingUsageSampler openAIMissingUsageLogSampler
func (s *openAIMissingUsageLogSampler) sample(now time.Time) (logNow bool, total uint64, suppressed uint64) {
total = s.total.Add(1)
nowNanos := now.UnixNano()
for {
last := s.lastLog.Load()
if last != 0 && nowNanos-last < int64(openAIMissingUsageLogInterval) {
s.suppressed.Add(1)
return false, total, 0
}
if s.lastLog.CompareAndSwap(last, nowNanos) {
return true, total, s.suppressed.Swap(0)
}
}
}
func logOpenAISuccessMissingUsage(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, usage *OpenAIUsage, terminalEvent string, clientDisconnected bool) {
if resp == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || openAIUsageHasTokens(usage) {
return
}
terminalEvent = strings.TrimSpace(terminalEvent)
if terminalEvent != "response.completed" && terminalEvent != "response.done" && terminalEvent != "[DONE]" && terminalEvent != "json" {
return
}
logNow, total, suppressed := openAIMissingUsageSampler.sample(time.Now())
if !logNow {
return
}
accountID := int64(0)
accountType := ""
if account != nil {
accountID = account.ID
accountType = string(account.Type)
}
inboundEndpoint := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
inboundEndpoint = c.Request.URL.Path
}
logger.FromContext(ctx).With(
zap.String("component", "service.openai_gateway"),
zap.Int64("account_id", accountID),
zap.String("account_type", accountType),
zap.String("inbound_endpoint", inboundEndpoint),
zap.String("upstream_request_id", strings.TrimSpace(resp.Header.Get("x-request-id"))),
zap.Int("upstream_status_code", resp.StatusCode),
zap.String("terminal_event", terminalEvent),
zap.Bool("client_disconnected", clientDisconnected),
zap.Uint64("missing_usage_total", total),
zap.Uint64("suppressed_since_last", suppressed),
).Warn("openai_usage.success_missing_usage")
}
func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
if len(body) == 0 || !gjson.ValidBytes(body) {
return OpenAIUsage{}, false
@@ -1320,6 +1508,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
return nil, fmt.Errorf("parse response: invalid json response")
}
usage := &usageValue
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
// Replace model in response if needed
if originalModel != mappedModel {
@@ -1383,9 +1572,20 @@ func bodyHasSSEFraming(body []byte) bool {
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
bodyText := string(body)
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
msg := extractOpenAISSEErrorMessage(terminalPayload)
if msg == "" {
msg = "Upstream compact response failed"
}
if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
return nil, compactErr
}
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
}
finalResponse, ok := extractCodexFinalResponse(bodyText)
usage := &OpenAIUsage{}
usage := s.parseSSEUsageFromBody(bodyText)
if ok {
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
*usage = parsedUsage
@@ -1418,15 +1618,6 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && terminalType == "response.failed" {
msg := extractOpenAISSEErrorMessage(terminalPayload)
if msg == "" {
msg = "Upstream compact response failed"
}
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
}
usage = s.parseSSEUsageFromBody(bodyText)
if originalModel != mappedModel {
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
}
@@ -1434,6 +1625,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
s.relayOpenAICodexTurnState(c, account, resp.Header)
contentType := "application/json; charset=utf-8"
@@ -1460,13 +1652,12 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) {
var terminalType string
var terminalPayload []byte
forEachOpenAISSEDataPayload(body, func(data []byte) {
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
if terminalPayload != nil {
return
}
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
switch eventType {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
terminalType = eventType
terminalPayload = append([]byte(nil), data...)
}
@@ -1489,6 +1680,54 @@ func extractOpenAISSEErrorMessage(payload []byte) string {
return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload)))
}
func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallbackMessage string) string {
responseID = strings.TrimSpace(responseID)
if responseID == "" {
responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
}
errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String())
if errorType == "" {
errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String())
}
code := strings.TrimSpace(gjson.GetBytes(source, "error.code").String())
if code == "" {
code = strings.TrimSpace(gjson.GetBytes(source, "response.error.code").String())
}
if code == "" {
code = "upstream_error"
}
message := extractOpenAISSEErrorMessage(source)
if message == "" {
message = strings.TrimSpace(fallbackMessage)
}
if message == "" {
message = "Upstream response failed"
}
errorBody := gin.H{"code": code, "message": message}
if errorType != "" {
errorBody["type"] = errorType
}
response := gin.H{
"id": responseID,
"object": "response",
"status": "failed",
"output": []any{},
"error": errorBody,
}
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
payload, err := marshalOpenAIUpstreamJSON(gin.H{
"type": "response.failed",
"response": response,
})
if err != nil {
// All values above are JSON primitives, so this is only a defensive fallback.
payload = []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return "event: response.failed\ndata: " + string(payload) + "\n\n"
}
func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string, clientOutputStarted bool) ([]byte, bool) {
eventType = strings.TrimSpace(eventType)
isFailedEvent := eventType == "response.failed"
@@ -1577,14 +1816,13 @@ func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.R
func extractCodexFinalResponse(body string) ([]byte, bool) {
var finalResponse []byte
forEachOpenAISSEDataPayload(body, func(data []byte) {
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
if finalResponse != nil {
return
}
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
data = normalized
}
eventType := gjson.GetBytes(data, "type").String()
if eventType == "response.done" || eventType == "response.completed" {
if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" {
finalResponse = []byte(response.Raw)
@@ -1717,11 +1955,12 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) {
}
items = append(items, json.RawMessage(item.Raw))
}
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
data = normalized
}
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" {
if eventType != "response.output_item.done" {
return
}
appendItem(gjson.GetBytes(data, "item"))
@@ -1730,8 +1969,8 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) {
// compaction 只在 added 中"的混合形态;done 已含 compaction 时跳过,
// 避免同一 item 在无 id 可去重时被收集两份(Codex 要求恰好一个)。
if !hasCompactionItem {
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.added" {
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
if eventType != "response.output_item.added" {
return
}
item := gjson.GetBytes(data, "item")
@@ -1806,11 +2045,11 @@ func responsesOutputHasCompactionItem(response []byte) bool {
func findRawCompactionItemFromSSE(bodyText string) (json.RawMessage, bool) {
var found json.RawMessage
pick := func(eventType string) {
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
forEachOpenAISSEFrame(bodyText, func(effectiveType string, data []byte) {
if found != nil {
return
}
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != eventType {
if effectiveType != eventType {
return
}
item := gjson.GetBytes(data, "item")
@@ -1842,11 +2081,11 @@ func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) {
acc := apicompat.NewBufferedResponseAccumulator()
imageOutputs := make([]json.RawMessage, 0, 1)
seenImages := make(map[string]struct{})
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok {
imageOutputs = append(imageOutputs, imageOutput)
}
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
if responsesStreamEventMayContributeToOutput(eventType) {
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal(data, &event); err == nil {
@@ -1909,8 +2148,8 @@ func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct
func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage {
usage := &OpenAIUsage{}
forEachOpenAISSEDataPayload(body, func(data []byte) {
s.parseSSEUsageBytes(data, usage)
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
s.parseSSEUsageBytesWithType(data, eventType, usage)
})
return usage
}
@@ -28,7 +28,7 @@ func TestOpenAIStreamEventIsTerminalWithTypeMatchesExistingSemantics(t *testing.
{name: "invalid JSON", data: `{"type":`, want: false},
{name: "terminal with trailing garbage", data: `{"type":"response.completed"} trailing`, want: true},
{name: "nonterminal with trailing garbage", data: `{"type":"response.output_text.delta"} trailing`, want: false},
{name: "type whitespace remains nonterminal", data: `{"type":" response.completed "}`, want: false},
{name: "type whitespace is normalized", data: `{"type":" response.completed "}`, want: true},
}
for _, tt := range tests {
@@ -98,6 +98,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
zap.String("upstream_model", upstreamModel),
zap.Bool("stream", clientStream),
)
SetOpsUpstreamModel(c, upstreamModel)
// Build and send upstream request via the shared CC pipeline
apiKey, targetURL, err := s.resolveCCFallbackTarget(account)
@@ -77,6 +77,26 @@ func TestAdaptOpenAIResponsesClientToolsRejectsTrailingData(t *testing.T) {
}
}
func TestResponsesFunctionUpstreamsLowerToolSearchDiscoveryOutput(t *testing.T) {
body := []byte(`{"tools":[{"type":"tool_search"}],"input":[{"type":"tool_search_output","call_id":"search_1","tools":[{"type":"namespace","name":"github"}],"status":"completed","execution":"client"}]}`)
adapters := map[string]func([]byte) ([]byte, apicompat.ResponsesClientToolMapping, error){
"OpenAI API-key": adaptOpenAIResponsesClientTools,
"Grok": adaptGrokResponsesClientTools,
}
for name, adapt := range adapters {
t.Run(name, func(t *testing.T) {
adapted, mapping, err := adapt(body)
require.NoError(t, err)
require.True(t, mapping.ToolSearch)
require.Equal(t, "function_call_output", gjson.GetBytes(adapted, "input.0.type").String())
require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, gjson.GetBytes(adapted, "input.0.output").String())
require.False(t, gjson.GetBytes(adapted, "input.0.tools").Exists())
require.False(t, gjson.GetBytes(adapted, "input.0.status").Exists())
require.False(t, gjson.GetBytes(adapted, "input.0.execution").Exists())
})
}
}
func TestClearOpenAIResponsesClientToolMappingRemovesStaleContextState(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -66,11 +66,25 @@ func explicitOpenAISessionID(c *gin.Context, body []byte) string {
sessionID := explicitOpenAIHeaderSessionID(c)
if sessionID == "" && len(body) > 0 {
sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String())
}
return sessionID
}
// openAIRequestPayloadView unwraps Responses WebSocket event envelopes while
// leaving ordinary HTTP objects untouched even when they contain a response
// field for another purpose.
func openAIRequestPayloadView(body []byte) gjson.Result {
root := parseRawJSONView(body)
eventType := strings.ToLower(strings.TrimSpace(root.Get("type").String()))
if strings.HasPrefix(eventType, "response.") {
if response := root.Get("response"); response.Exists() && response.IsObject() {
return response
}
}
return root
}
// explicitOpenAIRequestSessionID extends the common OpenAI session signals
// with Grok's native conversation header only for requests authenticated to a
// Grok group. This keeps an unrelated x-grok-conv-id header from changing
@@ -91,7 +105,7 @@ func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string {
sessionID = strings.TrimSpace(c.GetHeader(grokConversationIDHeader))
}
if sessionID == "" && len(body) > 0 {
sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String())
}
if sessionID == "" && isGrokRequestContext(c) && len(body) > 0 {
sessionID = grokPreviousResponseSessionSeed(body)
@@ -719,6 +733,17 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
return upstreamModel
}
// Compact mappings are keyed by the client-visible model. Prefer an exact
// compact rule before ordinary account mapping; otherwise a normal alias can
// hide the compact-specific rule and make scheduling disagree with Forward.
if requireCompact && account != nil {
if compactModel, matched := account.ResolveCompactMappedModel(strings.TrimSpace(requestedModel)); matched {
if compactModel = strings.TrimSpace(compactModel); compactModel != "" {
return compactModel
}
}
}
upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "")
if upstreamModel == "" {
return ""
@@ -732,6 +757,39 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
return normalizeOpenAIModelForUpstream(account, upstreamModel)
}
// ResolveOpenAIAccountUpstreamModelForRequest exposes the scheduler's exact
// account mapping chain to handler-side outcome reporting.
func ResolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string {
return resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
}
// resolveOpenAIForwardMappedModels is the shared account mapping chain for
// Forward callers. billingModel retains the ordinary mapping used for usage
// accounting, while upstreamModel is the model the scheduler has admitted.
func resolveOpenAIForwardMappedModels(account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string) {
requestedModel = strings.TrimSpace(requestedModel)
if account != nil && account.IsOpenAIPassthroughEnabled() {
billingModel = requestedModel
} else if account != nil {
billingModel = strings.TrimSpace(account.GetMappedModel(requestedModel))
}
if billingModel == "" {
billingModel = requestedModel
}
upstreamModel = resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
if strings.TrimSpace(upstreamModel) == "" {
upstreamModel = billingModel
}
return billingModel, upstreamModel
}
func resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel string) string {
if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" {
return upstreamModel
}
return strings.TrimSpace(billingModel)
}
func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) {
platform = NormalizeOpenAICompatiblePlatform(platform)
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
@@ -444,6 +444,7 @@ type OpenAIGatewayService struct {
openaiWSStateStore OpenAIWSStateStore
openaiScheduler OpenAIAccountScheduler
openaiWSPassthroughDialer openAIWSClientDialer
openaiWSSessionPreemptions openAIWSSessionPreemptRegistry
openaiAccountStats *openAIAccountRuntimeStats
openaiModelTransient *openAIAccountModelTransientState
openaiProxyStreamCircuit *openAIProxyStreamCircuit
@@ -454,6 +455,7 @@ type OpenAIGatewayService struct {
openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex
openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64
openaiAccountRuntimeBlockSequence atomic.Uint64
openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time
grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex
openaiOAuth429WindowStartUnixNano atomic.Int64
openaiOAuth429WindowCount atomic.Int64
@@ -1198,6 +1200,17 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco
return "", "", errors.New("access_token not found in credentials")
}
return accessToken, "oauth", nil
case AccountTypeSetupToken:
if !account.IsOpenAIOAuthLike() {
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
}
// OpenAI setup tokens are inference-only bearer credentials. They use the
// Codex OAuth forwarding protocol but have no refresh-token lifecycle.
accessToken := account.GetOpenAIAccessToken()
if accessToken == "" {
return "", "", errors.New("access_token not found in credentials")
}
return accessToken, "oauth", nil
case AccountTypeAPIKey:
if account.Platform == PlatformGrok {
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
@@ -719,7 +719,7 @@ func TestOpenAIGatewayService_Forward_CodexBridgeInjectionSetsImageBilling(t *te
require.Equal(t, "gpt-image-2", result.BillingModel)
}
func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t *testing.T) {
func TestOpenAIGatewayService_Forward_HTTPPreservesPreviousResponseIDForAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -756,7 +756,7 @@ func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
require.True(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
}
}
@@ -1768,7 +1768,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
require.Contains(t, string(failoverErr.ResponseBody), "Please retry later")
// 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。
// 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。
@@ -1840,8 +1840,8 @@ func TestOpenAIStreamingResponseFailedBeforeOutputRateLimitUsesPoolRetryPolicy(t
require.Equal(t, http.StatusTooManyRequests, opsEvents[len(opsEvents)-1].UpstreamStatusCode)
}
// 流内 rate limit 只产生 failover 错误,不写账号级限流/封禁状态:
// HTTP 200 流的 x-codex-* 头是正常配额快照,不能按 429 头驱动账号冷却。
// 流内 rate limit 进入 OAuth 同账号重试窗口,但不立即写账号级限流/封禁状态:
// HTTP 200 流的 x-codex-* 头不能让窗口内的账号提前失去调度资格。
func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{
@@ -1885,7 +1885,8 @@ func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
require.False(t, failoverErr.RetryableOnSameAccount)
require.True(t, failoverErr.RetryableOnSameAccount)
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
}
@@ -2957,6 +2958,26 @@ func TestNormalizeOpenAICompactRequestBodyPreservesCurrentCodexPayloadFields(t *
require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists())
}
func TestNormalizeOpenAICompactRequestBodyDropsParallelToolCallsWithoutUsableTools(t *testing.T) {
for _, body := range []string{
`{"model":"gpt-5.5","input":[],"parallel_tool_calls":true}`,
`{"model":"gpt-5.5","input":[],"tools":[],"parallel_tool_calls":false}`,
`{"model":"gpt-5.5","input":[],"tools":null,"parallel_tool_calls":true}`,
`{"model":"gpt-5.5","input":[],"tools":{},"parallel_tool_calls":true}`,
} {
normalized, changed, err := normalizeOpenAICompactRequestBody([]byte(body))
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists(), string(normalized))
}
withTools := []byte(`{"model":"gpt-5.5","tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
normalized, _, err := normalizeOpenAICompactRequestBody(withTools)
require.NoError(t, err)
require.True(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Bool())
}
func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -251,9 +251,18 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool
}
func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
// cyber_policy is request-scoped even when an intermediary wraps the
// provider response in a retryable 5xx status. Never punish or rotate the
// selected credential for it.
if hit, _, _ := detectOpenAICyberPolicy(upstreamBody); hit {
return false
}
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
return false
}
if isOpenAIUpstreamAccessStateError(upstreamMsg, upstreamBody) {
return true
}
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
return true
}
@@ -297,15 +306,139 @@ func newOpenAIUpstreamFailoverError(
failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
}
if isOpenAIUpstreamAccessStateError(upstreamMsg, responseBody) {
failoverErr.RetryableOnSameAccount = false
failoverErr.RequestScopedTransient = false
failoverErr.Stage = GatewayFailureStageAccountAuth
failoverErr.Scope = GatewayFailureScopeAccount
failoverErr.Reason = OpenAIUpstreamAccessStateReason
failoverErr.NextAccountAction = NextAccountRetry
failoverErr.ClientStatusCode = http.StatusBadGateway
failoverErr.ClientMessage = openAIUpstreamAccessUnavailableClientMessage
} else if requestScopedCapacity {
// Preserve the provider's actionable overload message after gateway
// retries are exhausted, but expose it as a retryable server_error.
failoverErr.ClientStatusCode = http.StatusServiceUnavailable
failoverErr.ClientMessage = openAICapacityShedClientMessage(upstreamMsg, responseBody)
}
return failoverErr
}
func (s *OpenAIGatewayService) newOpenAIAccountFailoverError(
account *Account,
statusCode int,
responseHeaders http.Header,
responseBody []byte,
upstreamMsg string,
shouldDisable bool,
retryableOnSameAccount bool,
) *UpstreamFailoverError {
oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccount(account, statusCode, shouldDisable)
failoverErr := newOpenAIUpstreamFailoverError(
statusCode,
responseHeaders,
responseBody,
upstreamMsg,
retryableOnSameAccount || oauth429Retry,
)
if oauth429Retry {
failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account)
failoverErr.SameAccountRetryDelay = openAIOAuth429SameAccountRetryDelay(responseHeaders, failoverErr.SameAccountRetryDeadline)
}
return failoverErr
}
const (
openAIUpstreamAccessUnavailableClientMessage = "Upstream access is temporarily unavailable, please retry later"
// OpenAIUpstreamAccessStateReason marks a provider credential whose
// account, workspace, or organization is unavailable.
OpenAIUpstreamAccessStateReason = GatewayFailureReason("openai_upstream_access_state")
// OpenAIHTTPContinuationUnsupportedReason identifies accounts that cannot
// preserve an official Responses HTTP continuation without dropping state.
OpenAIHTTPContinuationUnsupportedReason = GatewayFailureReason("openai_http_continuation_unsupported")
)
// isOpenAIUpstreamAccessStateError recognizes provider-side credential state
// failures from explicit structured fields. Valid JSON is never scanned as a
// blob because it may contain echoed user input with the same words.
func isOpenAIUpstreamAccessStateError(upstreamMsg string, body []byte) bool {
matchCode := func(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "deactivated_workspace" {
return true
}
for _, subject := range []string{"workspace", "account", "organization", "org"} {
for _, state := range []string{"deactivated", "disabled", "suspended"} {
if value == subject+"_"+state || value == state+"_"+subject {
return true
}
}
}
return false
}
matchMessage := func(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
for _, subject := range []string{"workspace", "account", "organization", "org"} {
for _, state := range []string{"deactivated", "disabled", "suspended"} {
if strings.Contains(value, subject+" is "+state) ||
strings.Contains(value, subject+" has been "+state) ||
strings.Contains(value, subject+" "+state) {
return true
}
}
}
return false
}
if matchMessage(upstreamMsg) {
return true
}
if len(body) == 0 {
return false
}
if !gjson.ValidBytes(body) {
return matchMessage(string(body)) || matchCode(string(body))
}
for _, path := range []string{"error.code", "response.error.code", "detail.code", "code"} {
if matchCode(gjson.GetBytes(body, path).String()) {
return true
}
}
for _, path := range []string{"error.message", "response.error.message", "detail.message", "detail", "message"} {
if matchMessage(gjson.GetBytes(body, path).String()) {
return true
}
}
return false
}
func openAICapacityShedClientMessage(upstreamMsg string, body []byte) string {
for _, candidate := range []string{
upstreamMsg,
gjson.GetBytes(body, "error.message").String(),
gjson.GetBytes(body, "response.error.message").String(),
gjson.GetBytes(body, "message").String(),
} {
candidate = sanitizeUpstreamErrorMessage(strings.TrimSpace(candidate))
if candidate != "" && isOpenAICapacityShedMessage(candidate) {
return candidate
}
}
return "Upstream service is temporarily overloaded, please retry later"
}
// IsOpenAIRequestBodyTooLarge reports whether another account may accept the
// same request even though the selected account rejected its serialized size.
func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
return e != nil && e.Reason == openAIRequestBodyTooLargeReason
}
// IsOpenAICapacityShed reports whether typed client fields were derived from a
// recognized provider overload rather than supplied by an unrelated failure.
func (e *UpstreamFailoverError) IsOpenAICapacityShed() bool {
return e != nil && e.RequestScopedTransient && isOpenAIRequestScopedCapacityShed("", e.ResponseBody)
}
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
+9 -5
View File
@@ -562,7 +562,7 @@ func (s *OpenAIGatewayService) ForwardImages(
switch account.Type {
case AccountTypeAPIKey:
return s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel)
case AccountTypeOAuth:
case AccountTypeOAuth, AccountTypeSetupToken:
return s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel)
default:
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
@@ -589,6 +589,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
return nil, err
}
SetOpsUpstreamModel(c, upstreamModel)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images request routing request_model=%s upstream_model=%s endpoint=%s account_type=%s",
@@ -658,11 +659,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
Message: upstreamMsg,
})
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
}
if isOpenAIUpstreamAccessStateError(upstreamMsg, respBody) {
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount)
}
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
}
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
}
@@ -902,8 +902,18 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
modelForCooldown = strings.TrimSpace(requestedModel[0])
}
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, modelForCooldown)
failoverErr := s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
resp.Header,
body,
upstreamMsg,
shouldDisable,
false,
)
shouldFailover := shouldDisable || (account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests && failoverErr.RetryableOnSameAccount)
kind := "http_error"
if shouldDisable {
if shouldFailover {
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@@ -916,12 +926,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
Message: upstreamMsg,
Detail: upstreamDetail,
})
if shouldDisable {
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: body,
RetryableOnSameAccount: false,
}
if shouldFailover {
return nil, failoverErr
}
// Surface the real upstream error to the client.
@@ -1760,11 +1766,15 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
Message: upstreamMsg,
})
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, requestModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
return nil, s.newOpenAIAccountFailoverError(
account,
resp.StatusCode,
resp.Header,
respBody,
upstreamMsg,
shouldDisable,
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
)
}
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
}
@@ -1879,8 +1889,15 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
}
responseBody := []byte(fmt.Sprintf(`{"error":{"type":"upstream_error","code":%q,"message":%q}}`, code, message))
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, responseBody, requestedModel)
return &UpstreamFailoverError{StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: headers,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)}
return s.newOpenAIAccountFailoverError(
account,
statusCode,
headers,
responseBody,
message,
shouldDisable,
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode),
)
}
var upstreamErr *OpenAIImagesUpstreamError
if !errors.As(err, &upstreamErr) {
@@ -1921,10 +1938,13 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr)
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.StatusCode, headers, responseBody, requestedModel)
return &UpstreamFailoverError{
StatusCode: upstreamErr.StatusCode,
ResponseBody: responseBody,
ResponseHeaders: headers,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode),
}
return s.newOpenAIAccountFailoverError(
account,
upstreamErr.StatusCode,
headers,
responseBody,
upstreamErr.clientMessage(),
shouldDisable,
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode),
)
}
@@ -12,6 +12,7 @@ import (
"net/textproto"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
@@ -471,6 +472,17 @@ func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T)
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
}
func TestAccountSupportsOpenAIImageCapability_SetupTokenSupportsNative(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
}
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityBasic))
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
}
func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(t *testing.T) {
account := &Account{
Platform: PlatformGrok,
@@ -1039,6 +1051,34 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailo
require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
}
func TestOpenAIGatewayServiceForwardImages_OAuth429CarriesSameAccountRetryWindow(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
svc := &OpenAIGatewayService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"1"}, "X-Request-Id": []string{"req_img_oauth_429"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)),
}}}
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
account := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "token-123"}}
startedAt := time.Now()
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay)
require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second)
}
func TestOpenAIImagesOAuthBodyReadTransportErrorFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -1291,6 +1331,52 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseU
require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
}
func TestOpenAIGatewayServiceForwardImages_APIKeyAccessStateUsesTypedFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat"}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := []byte(`{"error":{"code":"organization_suspended","message":"Organization has been suspended"}}`)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_images_access_state"},
},
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
account := &Account{
ID: 51,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "sk-test",
},
}
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
require.False(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, "req_images_access_state", failoverErr.ResponseHeaders.Get("x-request-id"))
require.False(t, c.Writer.Written())
}
func TestOpenAIGatewayServiceForwardImages_APIKeyStreamJSONResponseBillsImage(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
@@ -0,0 +1,24 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"io"
)
func decodeOpenAIJSONUseNumber(data []byte, dst any) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(dst); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values are not allowed")
}
return err
}
return nil
}
@@ -222,6 +222,126 @@ func TestResolveOpenAICompactForwardModel(t *testing.T) {
}
}
func TestResolveOpenAIForwardMappedModels_CompactMappingPrecedence(t *testing.T) {
conflictingMappings := map[string]any{
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
"compact_model_mapping": map[string]any{"gpt-5.5": "gpt-5.5-openai-compact"},
}
mappedOnlyCompact := map[string]any{
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
"compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
}
tests := []struct {
name string
account *Account
requireCompact bool
wantBilling string
wantUpstream string
}{
{
name: "compact uses client-visible model before ordinary mapping",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: conflictingMappings},
requireCompact: true,
wantBilling: "gpt-5.4",
wantUpstream: "gpt-5.5-openai-compact",
},
{
name: "non-compact uses ordinary mapping",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: conflictingMappings},
wantBilling: "gpt-5.4",
wantUpstream: "gpt-5.4",
},
{
name: "compact falls back to ordinary mapped model",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: mappedOnlyCompact},
requireCompact: true,
wantBilling: "gpt-5.4",
wantUpstream: "gpt-5.4-openai-compact",
},
{
name: "passthrough ignores ordinary mapping",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: conflictingMappings, Extra: map[string]any{"openai_passthrough": true}},
requireCompact: true,
wantBilling: "gpt-5.5",
wantUpstream: "gpt-5.5-openai-compact",
},
{
name: "raw chat fallback never applies compact mapping",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Credentials: conflictingMappings, Extra: map[string]any{"openai_responses_supported": false}},
requireCompact: true,
wantBilling: "gpt-5.4",
wantUpstream: "gpt-5.4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
billing, upstream := resolveOpenAIForwardMappedModels(tt.account, "gpt-5.5", tt.requireCompact)
if billing != tt.wantBilling {
t.Fatalf("billing model = %q, want %q", billing, tt.wantBilling)
}
if upstream != tt.wantUpstream {
t.Fatalf("upstream model = %q, want %q", upstream, tt.wantUpstream)
}
if scheduler := resolveOpenAIAccountUpstreamModelForRequest(tt.account, "gpt-5.5", tt.requireCompact); scheduler != upstream {
t.Fatalf("scheduler model %q disagrees with Forward model %q", scheduler, upstream)
}
})
}
}
func TestCanonicalOpenAIAccountSchedulingModelMatchesForwardSemantics(t *testing.T) {
tests := []struct {
name string
account *Account
model string
want string
}{
{
name: "OpenAI OAuth applies Codex alias normalization",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
model: "gpt-5.6",
want: "gpt-5.6-sol",
},
{
name: "OpenAI passthrough ignores ordinary account mapping",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Credentials: map[string]any{"model_mapping": map[string]any{"public": "private"}},
Extra: map[string]any{"openai_passthrough": true}},
model: "public",
want: "public",
},
{
name: "Grok OAuth does not inherit OpenAI Codex aliases",
account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth},
model: "gpt-5.6",
want: "gpt-5.6",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := canonicalOpenAIAccountSchedulingModel(tt.account, tt.model); got != tt.want {
t.Fatalf("canonical scheduling model = %q, want %q", got, tt.want)
}
})
}
}
func TestResolveOpenAIErrorSchedulingModelPrefersActualUpstreamModel(t *testing.T) {
if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", "gpt-5.5-openai-compact"); got != "gpt-5.5-openai-compact" {
t.Fatalf("error scheduling model = %q, want compact upstream model", got)
}
if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", ""); got != "gpt-5.4" {
t.Fatalf("empty upstream fallback = %q, want billing model", got)
}
}
func TestNormalizeCodexModel(t *testing.T) {
cases := map[string]string{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
@@ -666,7 +666,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
setOpenAIResponsesNamespaceNames(c, names)
result, err := (&OpenAIGatewayService{cfg: &config.Config{}}).handleNonStreamingResponsePassthrough(
context.Background(), resp, c, "gpt-5.5", "",
context.Background(), resp, c, &Account{ID: 91}, "gpt-5.5", "",
)
require.NoError(t, err)
require.NotNil(t, result)
@@ -95,6 +95,28 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI
}
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseReplay(t *testing.T) {
body := []byte(`{"type":"response.create","store":false,"parallel_tool_calls":true,"input":[` +
`{"type":"reasoning","id":"rs_drop","summary":[]},` +
`{"type":"reasoning","id":"rs_keep","call_id":"remove","encrypted_content":"cipher"},` +
`{"type":"message","content":"continue"}` +
`]}`)
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
})
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
require.Equal(t, int64(2), gjson.GetBytes(normalized, "input.#").Int())
require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
require.Equal(t, "message", gjson.GetBytes(normalized, "input.1.type").String())
}
func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
tests := []struct {
name string
@@ -0,0 +1,92 @@
//go:build unit
package service
import (
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/stretchr/testify/require"
)
func TestEffectiveOpenAISSEEventTypePrefersPayload(t *testing.T) {
t.Parallel()
require.Equal(t, "response.failed", effectiveOpenAISSEEventType([]byte(`{"type":"response.failed"}`), "error"))
require.Equal(t, "error", effectiveOpenAISSEEventType([]byte(`{"error":{"message":"failed"}}`), " error "))
require.JSONEq(t, `{"type":"error","error":{"message":"failed"}}`, openAICompatPayloadWithEventType(`{"type":"","error":{"message":"failed"}}`, "error"))
}
func TestExtractOpenAISSETerminalEventUsesEventField(t *testing.T) {
t.Parallel()
body := "event: error\n" +
"data: {\"error\":{\"message\":\"provider failed\"}}\n\n" +
"data: [DONE]\n\n"
eventType, payload, ok := extractOpenAISSETerminalEvent(body)
require.True(t, ok)
require.Equal(t, "error", eventType)
require.Equal(t, "provider failed", extractOpenAISSEErrorMessage(payload))
}
func TestParseSSEUsageEffectiveTerminalRules(t *testing.T) {
t.Parallel()
svc := &OpenAIGatewayService{}
usage := &OpenAIUsage{}
svc.parseSSEUsageBytesWithType([]byte(`{"usage":{"input_tokens":17,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}}}`), "response.in_progress", usage)
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"id":"resp_1"}}`), "response.completed", usage)
require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage)
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage)
require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage)
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":2,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage)
require.Equal(t, OpenAIUsage{InputTokens: 2}, *usage)
}
func TestOpenAICompatTerminalResponseSynthesizesBareError(t *testing.T) {
t.Parallel()
payload := []byte(`{"type":"error","code":"upstream_error","error":{"message":"provider failed"}}`)
event := &apicompat.ResponsesStreamEvent{Type: "error", Code: "upstream_error"}
response := openAICompatTerminalResponse(event, payload)
require.NotNil(t, response)
require.Equal(t, "failed", response.Status)
require.Equal(t, "upstream_error", response.Error.Code)
require.Equal(t, "provider failed", response.Error.Message)
}
func TestForEachOpenAISSEFrameDataTypeOverridesEventField(t *testing.T) {
t.Parallel()
var types []string
forEachOpenAISSEFrame(strings.Join([]string{
"event: response.in_progress",
`data: {"type":"response.completed","response":{"id":"resp_1"}}`,
"",
}, "\n"), func(eventType string, _ []byte) {
types = append(types, eventType)
})
require.Equal(t, []string{"response.completed"}, types)
}
func TestSampleOpenAIMissingUsageLogRateLimitsAndCounts(t *testing.T) {
var sampler openAIMissingUsageLogSampler
now := time.Unix(100, 0)
logNow, total, suppressed := sampler.sample(now)
require.True(t, logNow)
require.Equal(t, uint64(1), total)
require.Zero(t, suppressed)
logNow, total, _ = sampler.sample(now.Add(time.Second))
require.False(t, logNow)
require.Equal(t, uint64(2), total)
logNow, total, suppressed = sampler.sample(now.Add(openAIMissingUsageLogInterval))
require.True(t, logNow)
require.Equal(t, uint64(3), total)
require.Equal(t, uint64(1), suppressed)
}
@@ -0,0 +1,200 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
)
// normalizeOpenAIResponsesLegacyIngress accepts the Chat Completions-shaped
// payloads that a few Responses clients still emit. Native Responses input is
// always authoritative because this path has no separate full-replay attempt.
func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
var request map[string]any
if err := decodeOpenAIJSONUseNumber(body, &request); err != nil {
return body, false, fmt.Errorf("normalize legacy Responses ingress: %w", err)
}
changed := false
messagesValue, hasMessages := request["messages"]
if hasMessages {
messages, messagesAreArray := messagesValue.([]any)
if messagesAreArray && len(messages) > 0 {
legacy, err := convertLegacyResponsesMessages(body)
if err != nil {
return body, false, err
}
input, hasInput := request["input"]
hasNativeInput := hasInput && input != nil
if !hasNativeInput {
request["input"] = legacy.input
applyLegacyResponsesTopLevelFields(request, legacy)
delete(request, "previous_response_id")
changed = true
}
}
// messages is not a Responses field. When native input is present but the
// two histories are ambiguous, retain input and drop only the legacy alias.
delete(request, "messages")
changed = true
}
if prompt, hasPrompt := request["prompt"]; hasPrompt {
if input, hasInput := request["input"]; (!hasInput || input == nil) && prompt != nil {
request["input"] = prompt
}
delete(request, "prompt")
changed = true
}
if _, hasCommands := request["commands"]; hasCommands {
delete(request, "commands")
changed = true
}
if !changed {
return body, false, nil
}
normalized, err := marshalOpenAIUpstreamJSON(request)
if err != nil {
return body, false, fmt.Errorf("serialize legacy Responses ingress: %w", err)
}
return normalized, true, nil
}
type convertedLegacyResponsesMessages struct {
input any
instructions string
maxOutputTokens *int
temperature *float64
topP *float64
tools any
reasoning any
toolChoice any
serviceTier string
hasChatTools bool
hasChatToolChoice bool
hasReasoningEffort bool
}
func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) {
var converted convertedLegacyResponsesMessages
var chatRequest apicompat.ChatCompletionsRequest
if err := json.Unmarshal(body, &chatRequest); err != nil {
return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
}
responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest)
if err != nil {
return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
}
if len(responsesRequest.Input) == 0 {
return converted, fmt.Errorf("normalize legacy Responses messages: empty input")
}
if err := json.Unmarshal(responsesRequest.Input, &converted.input); err != nil {
return converted, fmt.Errorf("decode normalized legacy Responses input: %w", err)
}
converted.instructions = strings.TrimSpace(responsesRequest.Instructions)
converted.maxOutputTokens = responsesRequest.MaxOutputTokens
converted.temperature = responsesRequest.Temperature
converted.topP = responsesRequest.TopP
converted.serviceTier = strings.TrimSpace(responsesRequest.ServiceTier)
converted.hasReasoningEffort = strings.TrimSpace(chatRequest.ReasoningEffort) != ""
if responsesRequest.Reasoning != nil {
converted.reasoning = responsesRequest.Reasoning
}
var rawRequest map[string]any
if err := decodeOpenAIJSONUseNumber(body, &rawRequest); err != nil {
return converted, fmt.Errorf("inspect legacy Responses fields: %w", err)
}
converted.hasChatTools = hasChatStyleTools(rawRequest) || rawRequest["functions"] != nil
if converted.hasChatTools && len(responsesRequest.Tools) > 0 {
converted.tools = responsesRequest.Tools
}
converted.hasChatToolChoice = hasChatStyleToolChoice(rawRequest) || rawRequest["function_call"] != nil
if converted.hasChatToolChoice && len(responsesRequest.ToolChoice) > 0 {
choice, err := normalizeLegacyResponsesToolChoice(responsesRequest.ToolChoice)
if err != nil {
return converted, err
}
converted.toolChoice = choice
}
return converted, nil
}
func hasChatStyleTools(request map[string]any) bool {
tools, ok := request["tools"].([]any)
if !ok {
return false
}
for _, rawTool := range tools {
tool, _ := rawTool.(map[string]any)
if _, ok := tool["function"].(map[string]any); ok {
return true
}
}
return false
}
func hasChatStyleToolChoice(request map[string]any) bool {
choice, _ := request["tool_choice"].(map[string]any)
_, ok := choice["function"].(map[string]any)
return ok
}
func normalizeLegacyResponsesToolChoice(raw json.RawMessage) (any, error) {
var choice any
if err := json.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("normalize legacy Responses tool_choice: %w", err)
}
object, ok := choice.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(object["type"])) != "function" {
return choice, nil
}
function, ok := object["function"].(map[string]any)
if !ok {
return choice, nil
}
name := strings.TrimSpace(firstNonEmptyString(function["name"]))
if name == "" {
return choice, nil
}
return map[string]any{"type": "function", "name": name}, nil
}
func applyLegacyResponsesTopLevelFields(request map[string]any, legacy convertedLegacyResponsesMessages) {
if legacy.instructions != "" && strings.TrimSpace(firstNonEmptyString(request["instructions"])) == "" {
request["instructions"] = legacy.instructions
}
if legacy.maxOutputTokens != nil {
request["max_output_tokens"] = *legacy.maxOutputTokens
}
if legacy.temperature != nil {
request["temperature"] = *legacy.temperature
}
if legacy.topP != nil {
request["top_p"] = *legacy.topP
}
if legacy.hasChatTools && legacy.tools != nil {
request["tools"] = legacy.tools
}
if legacy.hasReasoningEffort && legacy.reasoning != nil {
request["reasoning"] = legacy.reasoning
}
if legacy.hasChatToolChoice && legacy.toolChoice != nil {
request["tool_choice"] = legacy.toolChoice
}
if legacy.serviceTier != "" {
request["service_tier"] = legacy.serviceTier
}
for _, key := range []string{"max_tokens", "max_completion_tokens", "reasoning_effort", "functions", "function_call"} {
delete(request, key)
}
}
@@ -0,0 +1,72 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestNormalizeOpenAIResponsesLegacyIngressMessagesOnly(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"system","content":"repo policy"},{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"hello\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}],"previous_response_id":"resp_stale"}`)
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "messages").Exists())
require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
require.Equal(t, "system", gjson.GetBytes(normalized, "input.0.role").String())
require.Equal(t, "repo policy", gjson.GetBytes(normalized, "input.0.content").String())
require.Equal(t, "function_call", gjson.GetBytes(normalized, "input.1.type").String())
require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.1.call_id").String())
require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.2.type").String())
require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.2.call_id").String())
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "stream").Type)
}
func TestNormalizeOpenAIResponsesLegacyIngressConvertsChatTopLevelFields(t *testing.T) {
body := []byte(`{"model":"gpt-4.1","messages":[{"role":"user","content":"weather?"}],"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup weather","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}},"max_tokens":64,"reasoning_effort":"high","service_tier":"flex","temperature":0.2,"top_p":0.7}`)
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "user", gjson.GetBytes(normalized, "input.0.role").String())
require.Equal(t, "function", gjson.GetBytes(normalized, "tools.0.type").String())
require.Equal(t, "lookup", gjson.GetBytes(normalized, "tools.0.name").String())
require.False(t, gjson.GetBytes(normalized, "tools.0.function").Exists())
require.Equal(t, "function", gjson.GetBytes(normalized, "tool_choice.type").String())
require.Equal(t, "lookup", gjson.GetBytes(normalized, "tool_choice.name").String())
require.False(t, gjson.GetBytes(normalized, "tool_choice.function").Exists())
require.Equal(t, int64(128), gjson.GetBytes(normalized, "max_output_tokens").Int())
require.Equal(t, "high", gjson.GetBytes(normalized, "reasoning.effort").String())
require.Equal(t, "auto", gjson.GetBytes(normalized, "reasoning.summary").String())
require.Equal(t, "flex", gjson.GetBytes(normalized, "service_tier").String())
require.False(t, gjson.GetBytes(normalized, "max_tokens").Exists())
require.False(t, gjson.GetBytes(normalized, "reasoning_effort").Exists())
}
func TestNormalizeOpenAIResponsesLegacyIngressNativeInputRemainsAuthoritative(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":[{"id":"msg_native","type":"message","role":"user","content":[{"type":"input_text","text":"keep me"}]}],"messages":[{"role":"user","content":"do not merge"}],"tools":[{"type":"function","function":{"name":"native_shape"}}],"tool_choice":{"type":"function","function":{"name":"native_shape"}},"previous_response_id":"resp_keep"}`)
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "messages").Exists())
require.Equal(t, "msg_native", gjson.GetBytes(normalized, "input.0.id").String())
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tools.0.function.name").String())
require.False(t, gjson.GetBytes(normalized, "tools.0.name").Exists())
require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tool_choice.function.name").String())
require.Equal(t, "resp_keep", gjson.GetBytes(normalized, "previous_response_id").String())
}
func TestNormalizeOpenAIResponsesLegacyIngressKeepsPromptAliasAndDropsCommands(t *testing.T) {
body := []byte(`{"model":"gpt-4.1","prompt":"hello","commands":[{"name":"legacy"}]}`)
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "hello", gjson.GetBytes(normalized, "input").String())
require.False(t, gjson.GetBytes(normalized, "prompt").Exists())
require.False(t, gjson.GetBytes(normalized, "commands").Exists())
}
@@ -1,7 +1,6 @@
package service
import (
"bytes"
"strings"
)
@@ -62,91 +61,16 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an
return true
}
func truncateOpenAIResponsesInputText(reqBody map[string]any) bool {
input, ok := reqBody["input"].([]any)
if !ok || len(input) == 0 {
return false
}
modified := false
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
if isCodexToolCallOutputItemType(itemType) {
if output, ok := item["output"].(string); ok {
if truncated, changed := truncateOpenAIResponsesInputString(output); changed {
item["output"] = truncated
modified = true
}
}
}
if truncateOpenAIResponsesMessageText(item) {
modified = true
}
}
return modified
}
func openAIResponsesInputMayNeedTruncation(body []byte) bool {
if len(body) <= openAIResponsesInputTextMaxChars {
return false
}
if bytes.Contains(body, []byte(`"text"`)) && bytes.Contains(body, []byte(`"content"`)) {
return true
}
for _, itemType := range []string{
"function_call_output",
"tool_search_output",
"custom_tool_call_output",
"mcp_tool_call_output",
} {
if bytes.Contains(body, []byte(itemType)) {
return true
}
}
func truncateOpenAIResponsesInputText(_ map[string]any) bool {
// Do not silently rewrite client or tool output. If an upstream enforces a
// text limit, forwarding the original value preserves its explicit error for
// the client and the normal Ops error pipeline. This compatibility shim is
// retained until the two callers can remove the old mutation hook together.
return false
}
func truncateOpenAIResponsesMessageText(item map[string]any) bool {
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
role := strings.TrimSpace(firstNonEmptyString(item["role"]))
if itemType != "message" && role == "" {
return false
}
parts, ok := item["content"].([]any)
if !ok {
return false
}
modified := false
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
continue
}
text, ok := part["text"].(string)
if !ok {
continue
}
if truncated, changed := truncateOpenAIResponsesInputString(text); changed {
part["text"] = truncated
modified = true
}
}
return modified
}
func truncateOpenAIResponsesInputString(value string) (string, bool) {
if len(value) <= openAIResponsesInputTextMaxChars {
return value, false
}
chars := 0
for index := range value {
if chars == openAIResponsesInputTextMaxChars {
return value[:index], true
}
chars++
}
return value, false
func openAIResponsesInputMayNeedTruncation(_ []byte) bool {
// See truncateOpenAIResponsesInputText. Returning false also avoids decoding
// very large bodies solely for a mutation that must not happen.
return false
}
@@ -5,7 +5,6 @@ import (
"net/http"
"strings"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -71,9 +70,11 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
})
}
func TestTruncateOpenAIResponsesInputText(t *testing.T) {
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars+1)
func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) {
atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars)
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中"
input := []any{
map[string]any{"type": "function_call_output", "call_id": "limit", "output": atLimit},
map[string]any{"type": "function_call_output", "call_id": "a", "output": oversized},
map[string]any{"type": "tool_search_output", "call_id": "b", "output": oversized},
map[string]any{"type": "custom_tool_call_output", "call_id": "c", "output": oversized},
@@ -88,35 +89,25 @@ func TestTruncateOpenAIResponsesInputText(t *testing.T) {
}
reqBody := map[string]any{"input": input}
require.True(t, truncateOpenAIResponsesInputText(reqBody))
for _, rawItem := range input[:4] {
require.False(t, truncateOpenAIResponsesInputText(reqBody))
require.Equal(t, atLimit, input[0].(map[string]any)["output"])
for _, rawItem := range input[1:5] {
item := rawItem.(map[string]any)
require.Len(t, item["output"].(string), openAIResponsesInputTextMaxChars)
require.Equal(t, oversized, item["output"])
}
content := input[4].(map[string]any)["content"].([]any)
content := input[5].(map[string]any)["content"].([]any)
require.Equal(t, "short", content[0].(map[string]any)["text"])
require.Len(t, content[1].(map[string]any)["text"].(string), openAIResponsesInputTextMaxChars)
require.Equal(t, oversized, content[1].(map[string]any)["text"])
}
func TestTruncateOpenAIResponsesInputStringPreservesUTF8Boundary(t *testing.T) {
value := strings.Repeat("a", openAIResponsesInputTextMaxChars-1) + "中中"
got, changed := truncateOpenAIResponsesInputString(value)
require.True(t, changed)
require.True(t, utf8.ValidString(got))
require.Equal(t, openAIResponsesInputTextMaxChars, utf8.RuneCountInString(got))
require.Equal(t, "中", got[len(got)-len("中"):])
}
func TestOpenAIResponsesInputMayNeedTruncation(t *testing.T) {
func TestOpenAIResponsesInputNeverRequestsPreemptiveTruncation(t *testing.T) {
short := []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`)
largeUnrelated := []byte(`{"input":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}`)
largeOutput := []byte(`{"input":[{"type":"function_call_output","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
require.False(t, openAIResponsesInputMayNeedTruncation(short))
require.False(t, openAIResponsesInputMayNeedTruncation(largeUnrelated))
require.True(t, openAIResponsesInputMayNeedTruncation(largeOutput))
require.False(t, openAIResponsesInputMayNeedTruncation(largeOutput))
}
func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *testing.T) {
@@ -139,8 +130,9 @@ func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *t
require.Empty(t, gjson.GetBytes(upstream.bodies[0], "input").Array())
}
func TestOpenAIGatewayService_TruncatesOversizedToolOutputBeforeForward(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
func TestOpenAIGatewayService_PreservesOversizedToolOutputForUpstream(t *testing.T) {
oversized := strings.Repeat("x", openAIResponsesInputTextMaxChars) + "中"
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + oversized + `"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
@@ -155,5 +147,5 @@ func TestOpenAIGatewayService_TruncatesOversizedToolOutputBeforeForward(t *testi
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1)
require.Equal(t, openAIResponsesInputTextMaxChars, len(gjson.GetBytes(upstream.bodies[0], "input.1.output").String()))
require.Equal(t, oversized, gjson.GetBytes(upstream.bodies[0], "input.1.output").String())
}
@@ -14,10 +14,16 @@ func openAIResponsesInputItemIDPrefix(itemType string) (string, bool) {
return "msg", true
case "reasoning":
return "rs", true
case "web_search_call":
return "ws", true
case "custom_tool_call":
return openAIResponsesToolCallIDPrefix(itemType), true
case "tool_search_call":
return openAIResponsesToolCallIDPrefix(itemType), true
case "custom_tool_call_output":
// Although custom calls use ctc IDs, OpenAI validates replayed custom
// call output item IDs against the generic fc namespace.
return "fc", true
default:
if isCodexToolCallInputType(itemType) {
return openAIResponsesToolCallIDPrefix(itemType), true
@@ -40,14 +46,20 @@ func openAIResponsesToolCallIDPrefix(itemType string) string {
// Invalid replayed IDs are removed rather than rewritten because a fabricated
// ID may point at a different upstream object.
func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool {
if id == "" {
return false
}
prefix, constrained := openAIResponsesInputItemIDPrefix(itemType)
if !constrained {
return false
}
return !strings.HasPrefix(id, prefix)
return id == "" || !strings.HasPrefix(id, prefix)
}
func shouldStripOpenAIResponsesNonPairCallID(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "message", "reasoning", "image_generation_call":
return true
default:
return false
}
}
func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
@@ -56,40 +68,119 @@ func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
return body, false, nil
}
items := make([][]byte, 0)
changed := false
var sanitizeErr error
index := 0
type inputItem struct {
body []byte
itemType string
id string
callID string
stripID bool
stripCallID bool
drop bool
isObject bool
}
items := make([]inputItem, 0)
strippedIDs := make(map[string]struct{})
validCallIDs := make(map[string]struct{})
input.ForEach(func(_, item gjson.Result) bool {
currentIndex := index
index++
itemBody := []byte(item.Raw)
parsed := inputItem{body: []byte(item.Raw), isObject: item.IsObject()}
if item.IsObject() {
itemType := item.Get("type")
id := item.Get("id")
if itemType.Type == gjson.String && id.Type == gjson.String &&
shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) {
itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id")
if sanitizeErr != nil {
sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr)
return false
parsed.itemType = strings.TrimSpace(itemType.String())
parsed.callID = strings.TrimSpace(item.Get("call_id").String())
parsed.stripCallID = item.Get("call_id").Exists() && shouldStripOpenAIResponsesNonPairCallID(parsed.itemType)
if id.Type == gjson.String {
parsed.id = id.String()
parsed.stripID = shouldStripOpenAIResponsesInputItemID(parsed.itemType, parsed.id)
if parsed.stripID && parsed.id != "" {
strippedIDs[parsed.id] = struct{}{}
}
changed = true
}
if isCodexToolCallContextItemType(parsed.itemType) && parsed.callID != "" {
validCallIDs[parsed.callID] = struct{}{}
}
}
items = append(items, itemBody)
items = append(items, parsed)
return true
})
if sanitizeErr != nil {
return nil, false, sanitizeErr
hasSanitization := false
for _, item := range items {
if item.stripID || item.stripCallID {
hasSanitization = true
break
}
}
if !changed {
if !hasSanitization {
return body, false, nil
}
// First decide which outputs become dangling because their call_id points at
// an item ID that is being removed and no call item owns that call_id. This
// must happen before computing retained IDs: a dropped output cannot keep an
// item_reference alive merely because it used to have the same id.
for index := range items {
item := &items[index]
if !item.isObject || !isCodexToolCallOutputItemType(item.itemType) {
continue
}
if _, pointsAtStrippedID := strippedIDs[item.callID]; !pointsAtStrippedID {
continue
}
if _, hasMatchingCallID := validCallIDs[item.callID]; !hasMatchingCallID {
item.drop = true
}
}
removedItemIDs := make(map[string]struct{}, len(strippedIDs))
retainedItemIDs := make(map[string]struct{}, len(items))
for _, item := range items {
if item.id == "" || item.itemType == "item_reference" {
continue
}
if item.stripID || item.drop {
removedItemIDs[item.id] = struct{}{}
continue
}
retainedItemIDs[item.id] = struct{}{}
}
for id := range retainedItemIDs {
delete(removedItemIDs, id)
}
rebuiltItems := make([][]byte, 0, len(items))
for index, item := range items {
if item.isObject {
if item.itemType == "item_reference" {
if _, dangling := removedItemIDs[item.id]; dangling {
continue
}
}
if item.drop {
continue
}
}
itemBody := item.body
if item.stripID {
var err error
itemBody, err = sjson.DeleteBytes(itemBody, "id")
if err != nil {
return nil, false, fmt.Errorf("delete input.%d.id: %w", index, err)
}
}
if item.stripCallID {
var err error
itemBody, err = sjson.DeleteBytes(itemBody, "call_id")
if err != nil {
return nil, false, fmt.Errorf("delete input.%d.call_id: %w", index, err)
}
}
rebuiltItems = append(rebuiltItems, itemBody)
}
rebuiltInput := make([]byte, 0, len(input.Raw))
rebuiltInput = append(rebuiltInput, '[')
for i, item := range items {
for i, item := range rebuiltItems {
if i > 0 {
rebuiltInput = append(rebuiltInput, ',')
}
@@ -0,0 +1,154 @@
package service
import (
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestOpenAIResponsesInputItemIDPrefixUsesObservedOutputContracts(t *testing.T) {
tests := []struct {
itemType string
id string
strip bool
}{
{itemType: "message", id: "msg_123", strip: false},
{itemType: "message", id: "item_123", strip: true},
{itemType: "reasoning", id: "rs_123", strip: false},
{itemType: "reasoning", id: "item_123", strip: true},
{itemType: "function_call", id: "fc_123", strip: false},
{itemType: "function_call", id: "call_123", strip: true},
{itemType: "tool_call", id: "fc_123", strip: false},
{itemType: "local_shell_call", id: "fc_123", strip: false},
{itemType: "mcp_tool_call", id: "fc_123", strip: false},
{itemType: "custom_tool_call", id: "ctc_123", strip: false},
{itemType: "custom_tool_call", id: "fc_123", strip: true},
{itemType: "tool_search_call", id: "tsc_123", strip: false},
{itemType: "tool_search_call", id: "fc_123", strip: true},
{itemType: "web_search_call", id: "ws_123", strip: false},
{itemType: "web_search_call", id: "item_123", strip: true},
{itemType: "custom_tool_call_output", id: "fc_123", strip: false},
{itemType: "custom_tool_call_output", id: "ctco_123", strip: true},
// Do not impose an inferred contract on output types for which there is
// no observed upstream prefix rejection.
{itemType: "function_call_output", id: "fco_123", strip: false},
{itemType: "tool_search_output", id: "tso_123", strip: false},
{itemType: "mcp_tool_call_output", id: "mcpo_123", strip: false},
{itemType: "future_item", id: "item_123", strip: false},
}
for _, tt := range tests {
t.Run(tt.itemType+"/"+tt.id, func(t *testing.T) {
require.Equal(t, tt.strip, shouldStripOpenAIResponsesInputItemID(tt.itemType, tt.id))
})
}
}
func TestSanitizeOpenAIResponsesInputItemIDsKeepsReferenceGraphConsistent(t *testing.T) {
body := []byte(`{"input":[
{"type":"function_call","id":"item_bad_call","call_id":"call_valid","name":"lookup","arguments":"{}"},
{"type":"function_call_output","call_id":"call_valid","output":"preserve paired output"},
{"type":"function_call_output","call_id":"item_bad_call","output":"drop dangling output"},
{"type":"item_reference","id":"item_bad_call"},
{"type":"item_reference","id":"remote_valid"},
{"type":"custom_tool_call","id":"ctc_valid","call_id":"ctco_bad_output","name":"apply_patch","input":"patch"},
{"type":"custom_tool_call_output","id":"ctco_bad_output","call_id":"ctco_bad_output","output":"preserve by call_id"}
]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.True(t, changed)
items := gjson.GetBytes(sanitized, "input").Array()
require.Len(t, items, 5)
require.False(t, items[0].Get("id").Exists())
require.Equal(t, "call_valid", items[0].Get("call_id").String())
require.Equal(t, "preserve paired output", items[1].Get("output").String())
require.Equal(t, "remote_valid", items[2].Get("id").String())
require.Equal(t, "ctc_valid", items[3].Get("id").String())
require.False(t, items[4].Get("id").Exists())
require.Equal(t, "ctco_bad_output", items[4].Get("call_id").String())
}
func TestSanitizeOpenAIResponsesInputItemIDsLeavesUnrelatedReferencesUntouched(t *testing.T) {
body := []byte(`{"previous_response_id":"resp_1","input":[{"type":"item_reference","id":"remote_item"}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, sanitized)
}
func TestSanitizeOpenAIResponsesInputItemIDsPreservesReferenceToDuplicateRetainedID(t *testing.T) {
body := []byte(`{"input":[{"type":"function_call","id":"ctc_shared","call_id":"call_1"},{"type":"custom_tool_call","id":"ctc_shared","call_id":"call_2"},{"type":"item_reference","id":"ctc_shared"}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.1.id").String())
require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.2.id").String())
}
func TestSanitizeOpenAIResponsesInputItemIDsClosesReferencesAfterDroppingOutput(t *testing.T) {
body := []byte(`{"input":[
{"type":"function_call","id":"item_shared","call_id":"call_real"},
{"type":"function_call_output","id":"item_shared","call_id":"item_shared","output":"dangling"},
{"type":"item_reference","id":"item_shared"},
{"type":"function_call_output","id":"kept_output","call_id":"call_real","output":"kept"},
{"type":"item_reference","id":"kept_output"}
]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.True(t, changed)
require.Len(t, gjson.GetBytes(sanitized, "input").Array(), 3)
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.1.id").String())
require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.2.id").String())
second, changedAgain, err := sanitizeOpenAIResponsesInputItemIDs(sanitized)
require.NoError(t, err)
require.False(t, changedAgain)
require.Equal(t, sanitized, second)
}
func TestSanitizeOpenAIResponsesInputItemIDsStripsEmptyKnownIDsOnly(t *testing.T) {
body := []byte(`{"input":[{"type":"message","id":"","content":"hello"},{"type":"future_item","id":""}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
require.True(t, gjson.GetBytes(sanitized, "input.1.id").Exists())
}
func TestSanitizeOpenAIResponsesInputItemIDsStripsOnlyNonPairCallIDs(t *testing.T) {
body := []byte(`{"input":[
{"type":"message","call_id":"remove_message","content":"hi"},
{"type":"reasoning","call_id":"remove_reasoning","id":"rs_keep","encrypted_content":"cipher","summary":[]},
{"type":"image_generation_call","call_id":"remove_image","id":"ig_keep","status":"completed"},
{"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
{"type":"function_call_output","call_id":"keep_function","output":"ok"},
{"type":"custom_tool_call","call_id":"keep_custom","name":"patch","input":"x"},
{"type":"custom_tool_call_output","call_id":"keep_custom","output":"ok"},
{"type":"tool_search_call","call_id":"keep_search","arguments":"{}"},
{"type":"tool_search_output","call_id":"keep_search","output":"ok"},
{"type":"local_shell_call","call_id":"keep_shell","name":"shell","arguments":"{}"}
]}`)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
require.NoError(t, err)
require.True(t, changed)
for i := 0; i < 3; i++ {
require.False(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists())
}
for i := 3; i < 10; i++ {
require.True(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists())
}
}
@@ -42,7 +42,7 @@ func shouldFlattenOpenAIResponsesNamespaces(
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
if account == nil || !account.IsOpenAIOAuthLike() {
return false
}
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
@@ -58,7 +58,7 @@ func shouldFlattenOpenAIResponsesNamespaces(
// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
// namespaces because that protocol supports them and does not restore payloads.
func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) {
if account == nil || (!account.IsOpenAIOAuthLike() && !account.IsOpenAIApiKey()) {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
@@ -87,7 +87,7 @@ func shouldKeepOpenAIResponsesToolCallNamespaces(
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
if account == nil || !account.IsOpenAIOAuthLike() {
return false
}
if compactPath {
@@ -94,7 +94,7 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
// API Key 出口是标准 Responses API,不认识该字段。
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
}
for _, tt := range tests {
@@ -125,7 +125,7 @@ func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) {
{name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
}
@@ -7,7 +7,9 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -19,18 +21,54 @@ var (
openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`)
openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`)
openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`)
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`)
openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`)
openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`)
openAIResponsesToolParametersParamPattern = regexp.MustCompile(`(?i)^(?:tools|input)\[\d+\](?:\.tools\[\d+\])*(?:\.function)?\.parameters$`)
openAIResponsesMissingSchemaTypePattern = regexp.MustCompile(`(?i)\bgot\s+["']?type\s*:\s*["']?none["']?`)
)
type openAIResponsesRejectedFieldRetryState struct {
attempts int
mu sync.Mutex
budget *openAIResponsesRejectedFieldRetryBudget
seenBodyHashes map[[sha256.Size]byte]struct{}
}
type openAIResponsesRejectedFieldRetryBudget struct {
mu sync.Mutex
attempts int
}
const openAIResponsesRejectedFieldRetryBudgetContextKey = "openai_responses_rejected_field_retry_budget"
// openAIResponsesRejectedFieldRetryStateForRequest returns a fresh loop guard
// for one account attempt backed by the inbound request's shared retry budget.
// A later account may apply the same compatibility transform, while all account
// attempts together remain bounded.
func openAIResponsesRejectedFieldRetryStateForRequest(c *gin.Context, initialBody []byte) *openAIResponsesRejectedFieldRetryState {
var budget *openAIResponsesRejectedFieldRetryBudget
if c != nil {
if existing, ok := c.Get(openAIResponsesRejectedFieldRetryBudgetContextKey); ok {
budget, _ = existing.(*openAIResponsesRejectedFieldRetryBudget)
}
}
if budget == nil {
budget = &openAIResponsesRejectedFieldRetryBudget{}
if c != nil {
c.Set(openAIResponsesRejectedFieldRetryBudgetContextKey, budget)
}
}
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, budget)
}
func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState {
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, &openAIResponsesRejectedFieldRetryBudget{})
}
func newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody []byte, budget *openAIResponsesRejectedFieldRetryBudget) *openAIResponsesRejectedFieldRetryState {
state := &openAIResponsesRejectedFieldRetryState{
budget: budget,
seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1),
}
state.remember(initialBody)
@@ -38,15 +76,22 @@ func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIRespon
}
func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool {
if s == nil || len(nextBody) == 0 || s.attempts >= maxOpenAIResponsesRejectedFieldRetries {
if s == nil || s.budget == nil || len(nextBody) == 0 {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
bodyHash := sha256.Sum256(nextBody)
if _, seen := s.seenBodyHashes[bodyHash]; seen {
return false
}
s.budget.mu.Lock()
defer s.budget.mu.Unlock()
if s.budget.attempts >= maxOpenAIResponsesRejectedFieldRetries {
return false
}
s.seenBodyHashes[bodyHash] = struct{}{}
s.attempts++
s.budget.attempts++
return true
}
@@ -54,6 +99,12 @@ func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) {
if s == nil || len(body) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.rememberLocked(body)
}
func (s *openAIResponsesRejectedFieldRetryState) rememberLocked(body []byte) {
if s.seenBodyHashes == nil {
s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1)
}
@@ -68,6 +119,17 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
if code == "invalid_function_parameters" &&
openAIResponsesToolParametersParamPattern.MatchString(param) &&
openAIResponsesMissingSchemaTypePattern.MatchString(message) {
retryBody, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
if err != nil {
return nil, "", false, fmt.Errorf("repair rejected tool parameter root type: %w", err)
}
if changed {
return retryBody, "tool parameter root type rejection", true, nil
}
}
cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message)
cacheParam := param
if cacheParam == "" {
@@ -88,8 +150,12 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
}
}
if isExplicitOpenAIResponsesFieldRejection(code, message) {
messageParam := openAIResponsesRejectedParamFromMessage(message)
if param != "" && messageParam != "" && param != messageParam {
return nil, "", false, nil
}
if param == "" {
param = openAIResponsesRejectedParamFromMessage(message)
param = messageParam
}
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
@@ -104,6 +170,13 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
}
return retryBody, "max_output_tokens parameter rejection", true, nil
}
if param == "truncation" && gjson.GetBytes(body, "truncation").Exists() {
retryBody, err := sjson.DeleteBytes(body, "truncation")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected truncation: %w", err)
}
return retryBody, "truncation parameter rejection", true, nil
}
}
messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message)
@@ -115,6 +188,11 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) {
return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index)
}
maxZeroContentParam := openAIResponsesMaxZeroContentParamFromMessage(message)
if index, ok := openAIResponsesRejectedContentIndex(param); ok &&
param == maxZeroContentParam && code == "array_above_max_length" {
return removeOpenAIResponsesRejectedReasoningContentAtIndex(body, index)
}
return nil, "", false, nil
}
@@ -135,6 +213,14 @@ func openAIResponsesRejectedParamFromMessage(message string) string {
return strings.ToLower(strings.TrimSpace(match[1]))
}
func openAIResponsesMaxZeroContentParamFromMessage(message string) string {
match := openAIResponsesMaxZeroContentMessagePattern.FindStringSubmatch(strings.TrimSpace(message))
if len(match) != 2 {
return ""
}
return strings.ToLower(strings.TrimSpace(match[1]))
}
func openAIResponsesInvalidTypeParamFromMessage(message string) string {
match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
if len(match) != 2 {
@@ -246,6 +332,20 @@ func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int)
}
}
func removeOpenAIResponsesRejectedReasoningContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
item := gjson.GetBytes(body, itemPath)
content := item.Get("content")
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != "reasoning" || !content.IsArray() || len(content.Array()) == 0 {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, itemPath+".content")
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected reasoning content at input[%d]: %w", index, err)
}
return retryBody, "indexed reasoning content maximum-length rejection", true, nil
}
func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
itemPath := fmt.Sprintf("input.%d", index)
itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
@@ -30,6 +30,33 @@ func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *tes
require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`)))
}
func TestOpenAIResponsesRejectedFieldRetryStateForRequestAllowsSameTransformAcrossAccounts(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
initialBody := []byte(`{"model":"gpt-5.5","truncation":"auto"}`)
retryBody := []byte(`{"model":"gpt-5.5"}`)
accountA := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
require.True(t, accountA.Allow(retryBody))
require.False(t, accountA.Allow(retryBody), "one account must not repeat the same transform")
accountB := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
require.NotSame(t, accountA, accountB)
require.Same(t, accountA.budget, accountB.budget)
require.True(t, accountB.Allow(retryBody), "a failover account must be allowed to apply the same transform")
}
func TestOpenAIResponsesRejectedFieldRetryStateForRequestSharesBoundedBudgetAcrossAccounts(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
state := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(fmt.Sprintf(`{"account":%d}`, attempt)))
require.True(t, state.Allow([]byte(`{"same":"retry"}`)))
}
overflow := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(`{"account":"overflow"}`))
require.False(t, overflow.Allow([]byte(`{"new":"retry"}`)))
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) {
tests := []struct {
name string
@@ -56,6 +83,11 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t
body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`),
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`),
},
{
name: "structured target conflicts with message target",
body: []byte(`{"max_output_tokens":4096,"truncation":"auto"}`),
responseBody: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: truncation.","param":"max_output_tokens"}}`),
},
}
for _, tt := range tests {
@@ -68,6 +100,33 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`)
responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`)
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "tool parameter root type rejection", reason)
require.Equal(t, "object", gjson.GetBytes(retryBody, "tools.0.parameters.type").String())
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotGuessAutomationRootType(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"}]}}]}`)
tests := []string{
`{"error":{"code":"invalid_function_parameters","message":"got type: \"None\"","param":"metadata.parameters"}}`,
`{"error":{"code":"invalid_request_error","message":"got type: \"None\"","param":"tools[0].parameters"}}`,
`{"error":{"code":"invalid_function_parameters","message":"expected an object","param":"tools[0].parameters"}}`,
}
for _, response := range tests {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, []byte(response))
require.NoError(t, err)
require.False(t, changed)
require.Nil(t, retryBody)
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) {
body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`)
@@ -192,6 +251,86 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullConten
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactReasoningContentAboveMaximumZero(t *testing.T) {
body := []byte(`{"input":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"remove"}],"summary":[]},{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`)
responseBody := []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content","type":"invalid_request_error"}}`)
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "indexed reasoning content maximum-length rejection", reason)
require.False(t, gjson.GetBytes(retryBody, "input.0.content").Exists())
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.1.content.0.text").String())
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeReasoningMaximumZeroMutations(t *testing.T) {
tests := []struct {
name string
body []byte
responseBody []byte
}{
{
name: "message content is not reasoning",
body: []byte(`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`),
responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
},
{
name: "structured param and message disagree",
body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]},{"type":"reasoning","content":[{"text":"keep too"}]}]}`),
responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[1].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
},
{
name: "different error code",
body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]}]}`),
responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
require.NoError(t, err)
require.False(t, changed)
require.Nil(t, retryBody)
})
}
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","truncation":"auto","input":"keep"}`)
responses := [][]byte{
[]byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
[]byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: truncation."}}`),
}
for _, responseBody := range responses {
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "truncation parameter rejection", reason)
require.False(t, gjson.GetBytes(retryBody, "truncation").Exists())
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
}
}
func TestOpenAIGatewayService_APIKeyRetriesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"truncation":"auto","input":"keep"}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "auto", gjson.GetBytes(upstream.bodies[0], "truncation").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists())
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input").String())
}
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) {
tests := []struct {
name string
@@ -3,95 +3,67 @@ package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/tidwall/gjson"
"unicode/utf8"
)
const (
// Keep this aligned with the gateway's default maximum request-body size. A
// caller with a larger custom limit must not make this compatibility pass an
// unbounded parser or patch accumulator.
openAIResponsesToolSchemaMaxBodySize = 256 << 20
openAIResponsesToolSchemaMaxDepth = 128
openAIResponsesToolSchemaMaxEdits = 1 << 20
openAIResponsesObjectUnionMaxSize = 1 << 20
openAIResponsesObjectUnionMaxDepth = 32
openAIResponsesToolSchemaFallbackType = `"object"`
)
var errOpenAIResponsesToolSchemaLimit = errors.New("OpenAI Responses tool schema safety limit exceeded")
// shouldSanitizeOpenAIResponsesToolSchemas centralizes the platform boundary
// for callers. These rewrites describe OpenAI Responses constraints, not the
// behavior of every provider routed through the generic OpenAI gateway.
func shouldSanitizeOpenAIResponsesToolSchemas(platform string) bool {
return platform == PlatformOpenAI
}
// sanitizeOpenAIResponsesToolSchemaPatterns removes only schema constraints
// containing regex lookaround, which OpenAI rejects. It deliberately does not
// descend into instance-valued keywords such as default, examples, const, or
// enum.
func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) {
if len(body) == 0 || !bytes.Contains(body, []byte(`"pattern"`)) {
if len(body) == 0 || !openAIResponsesBodyMayContainRegexLookaround(body) {
return body, false, nil
}
var root any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return nil, false, err
}
changed := false
var visitSchema func(any)
visitSchema = func(value any) {
node, ok := value.(map[string]any)
if !ok {
return
}
if pattern, ok := node["pattern"].(string); ok && hasRegexLookaround(pattern) {
delete(node, "pattern")
changed = true
}
for _, key := range []string{
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
"propertyNames", "unevaluatedProperties", "unevaluatedItems",
} {
visitSchema(node[key])
}
if items, ok := node["items"].(map[string]any); ok {
visitSchema(items)
} else if items, ok := node["items"].([]any); ok {
for _, child := range items {
visitSchema(child)
}
}
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
children, _ := node[key].([]any)
for _, child := range children {
visitSchema(child)
}
}
for _, key := range []string{"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} {
children, _ := node[key].(map[string]any)
for _, child := range children {
visitSchema(child)
}
}
if dependencies, ok := node["dependencies"].(map[string]any); ok {
for _, child := range dependencies {
visitSchema(child)
}
}
}
var visitTools func(any)
visitTools = func(value any) {
switch node := value.(type) {
case map[string]any:
if parameters, ok := node["parameters"]; ok {
visitSchema(parameters)
}
if function, ok := node["function"]; ok {
visitTools(function)
}
if tools, ok := node["tools"]; ok {
visitTools(tools)
}
case []any:
for _, child := range node {
visitTools(child)
}
}
}
if document, ok := root.(map[string]any); ok {
visitTools(document["tools"])
visitTools(document["input"])
}
if !changed {
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{removeLookaroundPatterns: true})
}
// sanitizeOpenAIResponsesToolParameterTypes replaces an explicitly null type
// on a tool's parameter root with object. It also repairs the known Codex
// missing root type when it is unambiguously implied by an object-only
// oneOf/anyOf. Other missing types and nested property schemas are left
// unchanged.
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
sanitized, err := json.Marshal(root)
if err != nil {
return nil, false, err
}
return sanitized, true, nil
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{
replaceNullParameterTypes: true,
injectObjectUnionRootObjectType: true,
})
}
func openAIResponsesBodyMayContainRegexLookaround(body []byte) bool {
// An opening parenthesis may be literal or encoded as JSON's canonical
// Unicode escape. Other lookaround characters may themselves be escaped, so
// the full decoded pattern is checked only after scoped parsing.
return bytes.Contains(body, []byte("(")) || bytes.Contains(body, []byte(`\u0028`))
}
func hasRegexLookaround(pattern string) bool {
@@ -99,121 +71,599 @@ func hasRegexLookaround(pattern string) bool {
strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(?<!")
}
type openAIResponsesToolSchemaOptions struct {
removeLookaroundPatterns bool
replaceNullParameterTypes bool
injectObjectUnionRootObjectType bool
}
type openAIResponsesToolSchemaContext uint8
const (
// 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体
// 造成无界递归。
openAIResponsesToolSchemaMaxDepth = 4
// JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法,
// 补成 object 与 upstream 对该工具的实际期望一致。
openAIResponsesToolSchemaFallbackType = `"object"`
// 显式 null 在 JSON 里只有这一种字面量形态。
openAIResponsesToolSchemaNullLiteral = "null"
openAIResponsesToolSchemaSkip openAIResponsesToolSchemaContext = iota
openAIResponsesToolSchemaDocument
openAIResponsesToolSchemaTools
openAIResponsesToolSchemaTool
openAIResponsesToolSchemaInput
openAIResponsesToolSchemaInputItem
openAIResponsesToolSchemaToolCarrier
openAIResponsesToolSchemaTypeProbe
openAIResponsesToolSchemaFunction
openAIResponsesToolSchema
openAIResponsesToolSchemaArray
openAIResponsesToolSchemaMap
openAIResponsesToolSchemaOrArray
)
// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的
// 绝对字节偏移表示,便于最后一次性拼接。
type openAIResponsesToolSchemaNullType struct {
offset int
length int
type openAIResponsesToolSchemaEdit struct {
start int
end int
replacement string
}
// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的
// tools[].parameters.type。
//
// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null,
// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的
// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在
// 账号池里反复重放同一份坏 Schema。
//
// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄
// 客户端语义,因此保持原样。
//
// 实现上先收集全部命中的绝对偏移,再一次性拼出新 body:逐个 sjson.SetBytes 每次
// 都会重扫并全量拷贝整个文档,命中 N 处就是 N 次全量拷贝,而 /v1/responses 的
// body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中。
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
type openAIResponsesToolSchemaParser struct {
body []byte
pos int
options openAIResponsesToolSchemaOptions
edits []openAIResponsesToolSchemaEdit
probeType string
}
func sanitizeOpenAIResponsesToolSchemas(
body []byte, options openAIResponsesToolSchemaOptions,
) ([]byte, bool, error) {
if len(body) > openAIResponsesToolSchemaMaxBodySize {
return body, false, nil
}
if !utf8.Valid(body) {
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid UTF-8")
}
p := openAIResponsesToolSchemaParser{body: body, options: options}
if err := p.parseValue(openAIResponsesToolSchemaDocument, false, 0); err != nil {
if errors.Is(err, errOpenAIResponsesToolSchemaLimit) {
return body, false, nil
}
return nil, false, err
}
p.skipWhitespace()
if p.pos != len(body) {
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: trailing data at byte %d", p.pos)
}
if !json.Valid(body) {
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid JSON")
}
if len(p.edits) == 0 {
return body, false, nil
}
return applyOpenAIResponsesToolSchemaEdits(body, p.edits)
}
hits := make([]openAIResponsesToolSchemaNullType, 0, 2)
collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits)
if input := gjson.GetBytes(body, "input"); input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if item.IsObject() {
collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits)
func (p *openAIResponsesToolSchemaParser) parseValue(
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
) error {
if depth > openAIResponsesToolSchemaMaxDepth {
return errOpenAIResponsesToolSchemaLimit
}
p.skipWhitespace()
if p.pos >= len(p.body) {
return p.syntaxError("expected value")
}
if p.body[p.pos] == '{' {
switch context {
case openAIResponsesToolSchemaInput:
context = openAIResponsesToolSchemaInputItem
case openAIResponsesToolSchemaOrArray, openAIResponsesToolSchemaArray:
context = openAIResponsesToolSchema
}
}
if context == openAIResponsesToolSchemaInputItem && p.body[p.pos] == '{' {
probe := openAIResponsesToolSchemaParser{body: p.body, pos: p.pos}
if err := probe.parseValue(openAIResponsesToolSchemaTypeProbe, false, depth); err != nil {
return err
}
switch probe.probeType {
case "function", "custom", "tool_search", "namespace":
context = openAIResponsesToolSchemaTool
case "additional_tools":
context = openAIResponsesToolSchemaToolCarrier
default:
context = openAIResponsesToolSchemaSkip
}
}
switch p.body[p.pos] {
case '{':
return p.parseObject(context, schemaRoot, depth+1)
case '[':
return p.parseArray(context, depth+1)
case '"':
_, _, err := p.parseString()
return err
case 't':
return p.parseKeyword("true")
case 'f':
return p.parseKeyword("false")
case 'n':
return p.parseKeyword("null")
default:
return p.parseNumber()
}
}
func (p *openAIResponsesToolSchemaParser) parseObject(
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
) error {
p.pos++
p.skipWhitespace()
if p.consume('}') {
return nil
}
previousComma := -1
deleteRunStart := -1
deleteRunPreviousComma := -1
rootTypeCount := 0
nullRootTypeStart := -1
nullRootTypeEnd := -1
parameterCount := 0
parameterValueStart := -1
parameterValueEnd := -1
for {
p.skipWhitespace()
memberStart := p.pos
keyStart, keyEnd, err := p.parseString()
if err != nil {
return err
}
key := p.body[keyStart:keyEnd]
p.skipWhitespace()
if !p.consume(':') {
return p.syntaxError("expected colon")
}
p.skipWhitespace()
valueStart := p.pos
childContext, childSchemaRoot := openAIResponsesToolSchemaChildContext(context, key)
if err := p.parseValue(childContext, childSchemaRoot, depth); err != nil {
return err
}
valueEnd := p.pos
p.skipWhitespace()
deleteMember := false
if context == openAIResponsesToolSchemaTypeProbe && openAIResponsesJSONStringEquals(key, "type") {
if value, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok {
p.probeType = strings.TrimSpace(value)
}
return true
})
}
if len(hits) == 0 {
return body, false, nil
}
}
if (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
openAIResponsesJSONStringEquals(key, "parameters") {
parameterCount++
parameterValueStart = valueStart
parameterValueEnd = valueEnd
}
if context == openAIResponsesToolSchema && openAIResponsesJSONStringEquals(key, "pattern") && p.options.removeLookaroundPatterns {
if pattern, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok && hasRegexLookaround(pattern) {
deleteMember = true
}
}
if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") {
// Duplicate JSON keys have parser-dependent effective values. Repair a
// root type only when it is unique rather than choosing a winner and
// potentially changing the client's effective schema.
rootTypeCount++
if bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) {
nullRootTypeStart = valueStart
nullRootTypeEnd = valueEnd
}
}
// tools 与 input 在 body 里的先后顺序由客户端决定,收集顺序不保证单调。
sort.Slice(hits, func(i, j int) bool { return hits[i].offset < hits[j].offset })
sanitized := make([]byte, 0, len(body)+len(hits)*len(openAIResponsesToolSchemaFallbackType))
cursor := 0
for _, hit := range hits {
// 收集阶段已逐个校验过区间,这里再挡一次重叠,保证拼接严格单调向前。
if hit.offset < cursor {
if p.pos >= len(p.body) {
return p.syntaxError("unterminated object")
}
if deleteMember && deleteRunStart < 0 {
deleteRunStart = memberStart
deleteRunPreviousComma = previousComma
}
if !deleteMember && deleteRunStart >= 0 {
// Remove through the delimiter after the deleted run, but retain the
// whitespace that originally preceded the next member.
if err := p.addEdit(deleteRunStart, previousComma+1, ""); err != nil {
return err
}
deleteRunStart = -1
deleteRunPreviousComma = -1
}
if p.body[p.pos] == ',' {
comma := p.pos
p.pos++
previousComma = comma
continue
}
sanitized = append(sanitized, body[cursor:hit.offset]...)
sanitized = append(sanitized, openAIResponsesToolSchemaFallbackType...)
cursor = hit.offset + hit.length
if p.body[p.pos] != '}' {
return p.syntaxError("expected comma or closing brace")
}
if deleteRunStart >= 0 {
start := deleteRunStart
if deleteRunPreviousComma >= 0 {
start = deleteRunPreviousComma
}
if err := p.addEdit(start, p.pos, ""); err != nil {
return err
}
}
if p.options.replaceNullParameterTypes && rootTypeCount == 1 && nullRootTypeStart >= 0 {
if err := p.addEdit(nullRootTypeStart, nullRootTypeEnd, openAIResponsesToolSchemaFallbackType); err != nil {
return err
}
}
if p.options.injectObjectUnionRootObjectType &&
(context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
parameterCount == 1 && parameterValueStart >= 0 {
if edit, ok := openAIMissingRootObjectUnionTypeEdit(
p.body[parameterValueStart:parameterValueEnd], parameterValueStart,
); ok {
if err := p.addEdit(edit.start, edit.end, edit.replacement); err != nil {
return err
}
}
}
p.pos++
return nil
}
sanitized = append(sanitized, body[cursor:]...)
return sanitized, true, nil
}
// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的
// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、
// custom 以及任何 hosted 工具上都同样非法。
func collectOpenAIResponsesToolSchemaNullTypes(
body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType,
) {
if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() {
return
func openAIMissingRootObjectUnionTypeEdit(raw []byte, absoluteStart int) (openAIResponsesToolSchemaEdit, bool) {
if len(raw) > openAIResponsesObjectUnionMaxSize {
return openAIResponsesToolSchemaEdit{}, false
}
tools.ForEach(func(_, tool gjson.Result) bool {
if !tool.IsObject() {
if !bytes.Contains(raw, []byte(`"oneOf"`)) && !bytes.Contains(raw, []byte(`"anyOf"`)) &&
!bytes.Contains(raw, []byte{'\\'}) {
return openAIResponsesToolSchemaEdit{}, false
}
var schema map[string]json.RawMessage
if len(raw) < 2 || raw[0] != '{' || json.Unmarshal(raw, &schema) != nil {
return openAIResponsesToolSchemaEdit{}, false
}
if _, hasType := schema["type"]; hasType || !openAIResponsesSchemaHasObjectOnlyUnion(schema, 0) {
return openAIResponsesToolSchemaEdit{}, false
}
return openAIResponsesToolSchemaEdit{
start: absoluteStart + 1,
end: absoluteStart + 1,
replacement: `"type":"object",`,
}, true
}
func openAIResponsesSchemaHasObjectOnlyUnion(schema map[string]json.RawMessage, depth int) bool {
if depth > openAIResponsesObjectUnionMaxDepth {
return false
}
found := false
for _, keyword := range []string{"oneOf", "anyOf"} {
raw, ok := schema[keyword]
if !ok {
continue
}
found = true
var branches []json.RawMessage
if json.Unmarshal(raw, &branches) != nil || len(branches) == 0 {
return false
}
for _, branch := range branches {
if !openAIResponsesSchemaIsObjectOnly(branch, depth+1) {
return false
}
}
}
return found
}
func openAIResponsesSchemaIsObjectOnly(raw json.RawMessage, depth int) bool {
if depth > openAIResponsesObjectUnionMaxDepth {
return false
}
var schema map[string]json.RawMessage
if json.Unmarshal(raw, &schema) != nil {
return false
}
if rawType, ok := schema["type"]; ok {
var schemaType string
return json.Unmarshal(rawType, &schemaType) == nil && schemaType == "object"
}
return openAIResponsesSchemaHasObjectOnlyUnion(schema, depth+1)
}
func (p *openAIResponsesToolSchemaParser) parseArray(
context openAIResponsesToolSchemaContext, depth int,
) error {
p.pos++
p.skipWhitespace()
if p.consume(']') {
return nil
}
childContext := openAIResponsesToolSchemaSkip
switch context {
case openAIResponsesToolSchemaTools:
childContext = openAIResponsesToolSchemaTool
case openAIResponsesToolSchemaInput:
childContext = openAIResponsesToolSchemaInputItem
case openAIResponsesToolSchemaArray, openAIResponsesToolSchemaOrArray:
childContext = openAIResponsesToolSchema
}
for {
if err := p.parseValue(childContext, false, depth); err != nil {
return err
}
p.skipWhitespace()
if p.consume(',') {
continue
}
if !p.consume(']') {
return p.syntaxError("expected comma or closing bracket")
}
return nil
}
}
func openAIResponsesToolSchemaChildContext(
context openAIResponsesToolSchemaContext, key []byte,
) (openAIResponsesToolSchemaContext, bool) {
switch context {
case openAIResponsesToolSchemaDocument:
switch {
case openAIResponsesJSONStringEquals(key, "tools"):
return openAIResponsesToolSchemaTools, false
case openAIResponsesJSONStringEquals(key, "input"):
return openAIResponsesToolSchemaInput, false
}
case openAIResponsesToolSchemaTool:
switch {
case openAIResponsesJSONStringEquals(key, "parameters"):
return openAIResponsesToolSchema, true
case openAIResponsesJSONStringEquals(key, "function"):
return openAIResponsesToolSchemaFunction, false
case openAIResponsesJSONStringEquals(key, "tools"):
return openAIResponsesToolSchemaTools, false
}
case openAIResponsesToolSchemaToolCarrier:
if openAIResponsesJSONStringEquals(key, "tools") {
return openAIResponsesToolSchemaTools, false
}
case openAIResponsesToolSchemaFunction:
switch {
case openAIResponsesJSONStringEquals(key, "parameters"):
return openAIResponsesToolSchema, true
case openAIResponsesJSONStringEquals(key, "tools"):
return openAIResponsesToolSchemaTools, false
}
case openAIResponsesToolSchema:
switch {
case openAIResponsesJSONStringMatchesAny(key,
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
"propertyNames", "unevaluatedProperties", "unevaluatedItems"):
return openAIResponsesToolSchema, false
case openAIResponsesJSONStringEquals(key, "items"):
return openAIResponsesToolSchemaOrArray, false
case openAIResponsesJSONStringMatchesAny(key, "anyOf", "oneOf", "allOf", "prefixItems"):
return openAIResponsesToolSchemaArray, false
case openAIResponsesJSONStringMatchesAny(key,
"properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"):
return openAIResponsesToolSchemaMap, false
}
case openAIResponsesToolSchemaMap:
return openAIResponsesToolSchema, false
}
return openAIResponsesToolSchemaSkip, false
}
func (p *openAIResponsesToolSchemaParser) parseString() (int, int, error) {
if p.pos >= len(p.body) || p.body[p.pos] != '"' {
return 0, 0, p.syntaxError("expected string")
}
start := p.pos
p.pos++
for p.pos < len(p.body) {
switch p.body[p.pos] {
case '"':
p.pos++
return start, p.pos, nil
case '\\':
p.pos++
if p.pos >= len(p.body) {
return 0, 0, p.syntaxError("unterminated string escape")
}
switch p.body[p.pos] {
case 'u':
if p.pos+4 >= len(p.body) {
return 0, 0, p.syntaxError("short unicode escape")
}
for _, digit := range p.body[p.pos+1 : p.pos+5] {
if !isOpenAIResponsesJSONHexDigit(digit) {
return 0, 0, p.syntaxError("invalid unicode escape")
}
}
p.pos += 5
continue
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
p.pos++
default:
return 0, 0, p.syntaxError("invalid string escape")
}
default:
if p.body[p.pos] < 0x20 {
return 0, 0, p.syntaxError("control character in string")
}
p.pos++
}
}
return 0, 0, p.syntaxError("unterminated string")
}
func isOpenAIResponsesJSONHexDigit(value byte) bool {
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
}
func (p *openAIResponsesToolSchemaParser) parseKeyword(keyword string) error {
if len(p.body)-p.pos < len(keyword) || string(p.body[p.pos:p.pos+len(keyword)]) != keyword {
return p.syntaxError("invalid literal")
}
p.pos += len(keyword)
return nil
}
func (p *openAIResponsesToolSchemaParser) parseNumber() error {
start := p.pos
if p.consume('-') && p.pos >= len(p.body) {
return p.syntaxError("invalid number")
}
if p.consume('0') {
if p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
return p.syntaxError("invalid leading zero")
}
} else {
if p.pos >= len(p.body) || p.body[p.pos] < '1' || p.body[p.pos] > '9' {
return p.syntaxError("invalid number")
}
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
p.pos++
}
}
if p.consume('.') {
fractionStart := p.pos
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
p.pos++
}
if p.pos == fractionStart {
return p.syntaxError("invalid fraction")
}
}
if p.pos < len(p.body) && (p.body[p.pos] == 'e' || p.body[p.pos] == 'E') {
p.pos++
if p.pos < len(p.body) && (p.body[p.pos] == '+' || p.body[p.pos] == '-') {
p.pos++
}
exponentStart := p.pos
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
p.pos++
}
if p.pos == exponentStart {
return p.syntaxError("invalid exponent")
}
}
if p.pos == start {
return p.syntaxError("invalid value")
}
return nil
}
func (p *openAIResponsesToolSchemaParser) addEdit(start, end int, replacement string) error {
if start < 0 || end < start || end > len(p.body) {
return p.syntaxError("invalid patch span")
}
if len(p.edits) >= openAIResponsesToolSchemaMaxEdits {
return errOpenAIResponsesToolSchemaLimit
}
p.edits = append(p.edits, openAIResponsesToolSchemaEdit{start: start, end: end, replacement: replacement})
return nil
}
func (p *openAIResponsesToolSchemaParser) skipWhitespace() {
for p.pos < len(p.body) {
switch p.body[p.pos] {
case ' ', '\t', '\n', '\r':
p.pos++
default:
return
}
}
}
func (p *openAIResponsesToolSchemaParser) consume(want byte) bool {
if p.pos < len(p.body) && p.body[p.pos] == want {
p.pos++
return true
}
return false
}
func (p *openAIResponsesToolSchemaParser) syntaxError(message string) error {
return fmt.Errorf("sanitize OpenAI Responses tool schemas: %s at byte %d", message, p.pos)
}
func decodeOpenAIResponsesJSONString(raw []byte) (string, error) {
decoded, err := strconv.Unquote(string(raw))
if err != nil {
return "", err
}
return decoded, nil
}
func openAIResponsesJSONStringEquals(raw []byte, want string) bool {
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
return false
}
if !bytes.Contains(raw, []byte{'\\'}) {
return bytes.Equal(raw[1:len(raw)-1], []byte(want))
}
decoded, err := decodeOpenAIResponsesJSONString(raw)
return err == nil && decoded == want
}
func openAIResponsesJSONStringMatchesAny(raw []byte, candidates ...string) bool {
for _, candidate := range candidates {
if openAIResponsesJSONStringEquals(raw, candidate) {
return true
}
// Responses 形态用顶层 parameters,ChatCompletions 形态用 function.parameters,
// 两种都可能出现在 Responses 请求里(见 normalizeCodexTools)。
for _, suffix := range []string{"parameters", "function.parameters"} {
params := tool.Get(suffix)
if !params.IsObject() {
continue
}
// gjson 用 Type==Null 同时表示「显式 null」和「路径不存在」,靠 Raw
// 区分:不存在时 Raw 为空串。
if typ := params.Get("type"); typ.Type == gjson.Null && typ.Raw == openAIResponsesToolSchemaNullLiteral {
appendOpenAIResponsesToolSchemaNullType(body, typ, hits)
}
}
// 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如
// input[234].tools[0].tools[3].parameters)。
collectOpenAIResponsesToolSchemaNullTypes(body, tool.Get("tools"), depth+1, hits)
return true
})
}
return false
}
// appendOpenAIResponsesToolSchemaNullType 先校验 gjson 给出的偏移确实指向原始
// body 上那段 null,再记录。gjson 对嵌套取值同样返回相对原始文档的绝对偏移,但
// Index 为 0 表示未知;偏移不可用时跳过该处而不是猜位置——少修一个工具只是维持
// 现状,拼错位置会损坏整个请求体。
func appendOpenAIResponsesToolSchemaNullType(
body []byte, typ gjson.Result, hits *[]openAIResponsesToolSchemaNullType,
) {
end := typ.Index + len(typ.Raw)
if typ.Index <= 0 || end > len(body) {
return
func decodeOpenAIResponsesJSONStringValue(raw []byte) (string, bool) {
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
return "", false
}
if !bytes.Equal(body[typ.Index:end], []byte(typ.Raw)) {
return
decoded, err := decodeOpenAIResponsesJSONString(raw)
return decoded, err == nil
}
func applyOpenAIResponsesToolSchemaEdits(
body []byte, edits []openAIResponsesToolSchemaEdit,
) ([]byte, bool, error) {
sort.Slice(edits, func(i, j int) bool {
if edits[i].start != edits[j].start {
return edits[i].start < edits[j].start
}
return edits[i].end < edits[j].end
})
merged := edits[:0]
for _, edit := range edits {
if len(merged) == 0 || edit.start >= merged[len(merged)-1].end {
merged = append(merged, edit)
continue
}
previous := &merged[len(merged)-1]
if previous.replacement != "" || edit.replacement != "" {
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: overlapping patches")
}
if edit.end > previous.end {
previous.end = edit.end
}
}
*hits = append(*hits, openAIResponsesToolSchemaNullType{offset: typ.Index, length: len(typ.Raw)})
delta := 0
for _, edit := range merged {
delta += len(edit.replacement) - (edit.end - edit.start)
}
sanitized := make([]byte, 0, len(body)+delta)
cursor := 0
for _, edit := range merged {
sanitized = append(sanitized, body[cursor:edit.start]...)
sanitized = append(sanitized, edit.replacement...)
cursor = edit.end
}
sanitized = append(sanitized, body[cursor:]...)
if !json.Valid(sanitized) {
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: patch produced invalid JSON")
}
return sanitized, true, nil
}
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"strconv"
"testing"
"github.com/stretchr/testify/require"
@@ -62,6 +63,59 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *tes
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists())
}
func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectOnlyRootUnion(t *testing.T) {
body := []byte(`{"tools":[
{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}}},{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}]}},
{"type":"function","name":"codex_app__automation_update","parameters":{"anyOf":[{"type":"object"}]}},
{"type":"function","name":"unrelated_tool","parameters":{"oneOf":[{"type":"object"}]}}
]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
for i := 0; i < 3; i++ {
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools."+strconv.Itoa(i)+".parameters.type").String())
}
require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.parameters.oneOf.0.properties.id.type").String())
}
func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedUnionKeyword(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","name":"other","parameters":{"one\u004ff":[{"type":"object"}]}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
}
func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectUnionMissingTypeSafetyBoundary(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "mixed union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"string"}]}}]}`},
{name: "unproven branch", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"properties":{}}]}}]}`},
{name: "empty union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[]}}]}`},
{name: "existing null handled by existing repair", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"oneOf":[{"type":"object"}]}}]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tt.body))
require.NoError(t, err)
if tt.name == "existing null handled by existing repair" {
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
return
}
require.False(t, changed)
require.Equal(t, tt.body, string(sanitized))
})
}
}
// 多轮历史:工具定义沉进 input 后,upstream 报错路径形如
// input[N].tools[i].tools[j].parameters,两层都要修。
func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) {
@@ -71,6 +125,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing
{
"type": "additional_tools",
"role": "developer",
"parameters": {"type": null},
"tools": [
{
"type": "namespace",
@@ -92,6 +147,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String())
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String())
require.Equal(t, "null", gjson.GetBytes(sanitized, "input.1.parameters.type").Raw)
// 原本合法的兄弟条目保持不变。
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String())
require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String())
@@ -110,6 +166,86 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testi
require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String())
}
func TestSanitizeOpenAIResponsesToolParameterTypes_InputItemShapes(t *testing.T) {
body := []byte(`{"input":[{"parameters":{"type":null},"name":"direct","type":"function"},{"function":{"name":"wrapped","parameters":{"type":null}},"type":"function"}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.0.parameters.type").String())
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.function.parameters.type").String())
}
func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotTreatMessagesAsTools(t *testing.T) {
body := []byte(`{"input":[{"type":"message","role":"user","parameters":{"type":null},"function":{"parameters":{"type":null}},"metadata":{"parameters":{"type":null}}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolParameterTypes_PreservesUntouchedBytes(t *testing.T) {
body := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : null},\"name\":\"x\"}], \"a\":-0.00\n}")
want := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : \"object\"},\"name\":\"x\"}], \"a\":-0.00\n}")
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, string(want), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedKey(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","parameters":{"ty\u0070e":null}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, `{"tools":[{"type":"function","parameters":{"ty\u0070e":"object"}}]}`, string(sanitized))
}
func TestSanitizeOpenAIResponsesToolParameterTypes_DuplicateTypeIsAmbiguousNoOp(t *testing.T) {
for _, body := range [][]byte{
[]byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":"string"}}]}`),
[]byte(`{"tools":[{"type":"function","parameters":{"type":"string","ty\u0070e":null}}]}`),
[]byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":null}}]}`),
} {
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(sanitized))
}
}
func TestSanitizeOpenAIResponsesToolSchemas_InvalidAndTrailingJSON(t *testing.T) {
for _, tc := range []struct {
name string
body []byte
call func([]byte) ([]byte, bool, error)
}{
{"null_type_trailing", []byte(`{"tools":[{"parameters":{"type":null}}]} trailing`), sanitizeOpenAIResponsesToolParameterTypes},
{"pattern_trailing", []byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}]} trailing`), sanitizeOpenAIResponsesToolSchemaPatterns},
{"invalid_utf8", append([]byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}],"bad":"`), 0xff, '"', '}'), sanitizeOpenAIResponsesToolSchemaPatterns},
} {
t.Run(tc.name, func(t *testing.T) {
_, changed, err := tc.call(tc.body)
require.Error(t, err)
require.False(t, changed)
})
}
}
func TestShouldSanitizeOpenAIResponsesToolSchemas_PlatformBoundary(t *testing.T) {
require.True(t, shouldSanitizeOpenAIResponsesToolSchemas(PlatformOpenAI))
for _, platform := range []string{PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformComposite, ""} {
require.False(t, shouldSanitizeOpenAIResponsesToolSchemas(platform), platform)
}
}
// 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。
func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) {
body := []byte(`{"tools":[
@@ -212,6 +348,143 @@ func TestSanitizeOpenAIResponsesToolSchemaPatterns_RemovesLookaroundOnly(t *test
require.Equal(t, "^[a-z]+$", gjson.GetBytes(sanitized, "tools.0.parameters.properties.id.pattern").String())
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_PreservesRawDocumentRepresentation(t *testing.T) {
body := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{\"pattern\":\"(?=drop)\", \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
want := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{ \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, string(want), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_AllMemberPositions(t *testing.T) {
body := []byte(`{"tools":[{"parameters":{"properties":{"first":{"pattern":"(?=a)","type":"string"},"middle":{"title":"x","pattern":"(?!b)","type":"string"},"last":{"type":"string","pattern":"(?<=c)"},"only":{"pattern":"(?<!d)"}}}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
for _, name := range []string{"first", "middle", "last", "only"} {
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name+".pattern").Exists(), name)
require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name).IsObject(), name)
}
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_TraversesConservativeSchemaKeywords(t *testing.T) {
body := []byte(`{"input":[{"type":"function","name":"direct","parameters":{"type":"object","properties":{"p":{"anyOf":[{"pattern":"(?=any)"}],"items":{"pattern":"(?!item)"}}},"$defs":{"d":{"pattern":"(?<=def)"}},"additionalProperties":{"pattern":"(?<!additional)"}}},{"type":"function","function":{"name":"wrapped","parameters":{"oneOf":[{"pattern":"(?=wrapped)"}]}}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
for _, path := range []string{
"input.0.parameters.properties.p.anyOf.0.pattern",
"input.0.parameters.properties.p.items.pattern",
"input.0.parameters.$defs.d.pattern",
"input.0.parameters.additionalProperties.pattern",
"input.1.function.parameters.oneOf.0.pattern",
} {
require.False(t, gjson.GetBytes(sanitized, path).Exists(), path)
}
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTraverseMessageMetadata(t *testing.T) {
body := []byte(`{"input":[{"parameters":{"pattern":"(?=missing-type)"}},{"type":"message","parameters":{"pattern":"(?=parameters)"},"function":{"parameters":{"pattern":"(?=function)"}},"metadata":{"tools":[{"type":"function","parameters":{"pattern":"(?=metadata)"}}]}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DecodesEscapedLookaround(t *testing.T) {
for _, pattern := range []string{`\u0028?\u003dfoo`, `(\u003f\u003dfoo)`} {
body := []byte(`{"tools":[{"parameters":{"type":"string","pattern":"` + pattern + `"}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.pattern").Exists())
}
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_EscapedKey(t *testing.T) {
body := []byte(`{"to\u006fls":[{"par\u0061meters":{"type":"string","pat\u0074ern":"(?=drop)"}}]}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, `{"to\u006fls":[{"par\u0061meters":{"type":"string"}}]}`, string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DuplicateKeysStayValid(t *testing.T) {
for _, tc := range []struct {
body string
want string
}{
{
`{"tools":[{"parameters":{"pattern":"(?=drop)","pattern":"^[a-z]+$","type":"string"}}]}`,
`{"tools":[{"parameters":{"pattern":"^[a-z]+$","type":"string"}}]}`,
},
{
`{"tools":[{"parameters":{"type":"string","pattern":"(?=first)","pattern":"(?!second)"}}]}`,
`{"tools":[{"parameters":{"type":"string"}}]}`,
},
} {
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns([]byte(tc.body))
require.NoError(t, err)
require.True(t, changed)
require.True(t, json.Valid(sanitized))
require.Equal(t, tc.want, string(sanitized))
}
}
func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
body := []byte(`{"tools":[{"type":"function","parameters":{"type":null,"pattern":"(?=drop)"}}]}`)
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
t.Run(accountType, func(t *testing.T) {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: accountType,
})
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.pattern").Exists())
})
}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformGrok,
Type: AccountTypeAPIKey,
})
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(normalized))
}
func TestSanitizeOpenAIResponsesToolSchemas_DepthLimitReturnsOriginal(t *testing.T) {
body := []byte(`{"tools":[{"parameters":`)
for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
body = append(body, []byte(`{"allOf":[`)...)
}
body = append(body, []byte(`{"pattern":"(?=too-deep)"}`)...)
for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
body = append(body, []byte(`]}`)...)
}
body = append(body, []byte(`}]}`)...)
require.True(t, json.Valid(body))
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, string(body), string(sanitized))
}
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTouchUserInputPattern(t *testing.T) {
body := []byte(`{"input":{"pattern":"(?=keep)"},"metadata":{"pattern":"(?!keep)"}}`)
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
@@ -279,3 +552,42 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits
return true
})
}
func BenchmarkSanitizeOpenAIResponsesToolSchemaPatterns_ByteSpanPatch(b *testing.B) {
var tools []any
for i := 0; i < 2000; i++ {
tools = append(tools, map[string]any{
"type": "function",
"name": "search",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{"query": map[string]any{"type": "string", "pattern": "(?=foo).*"}},
},
})
}
body, _ := json.Marshal(map[string]any{"model": "gpt-5.6", "tools": tools})
b.ReportAllocs()
b.SetBytes(int64(len(body)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = sanitizeOpenAIResponsesToolSchemaPatterns(body)
}
}
func BenchmarkSanitizeOpenAIResponsesToolParameterTypes_ByteSpanPatch(b *testing.B) {
body := make([]byte, 0, 256*2000)
body = append(body, []byte(`{"tools":[`)...)
for i := 0; i < 2000; i++ {
if i > 0 {
body = append(body, ',')
}
body = append(body, []byte(`{"type":"function","name":"automation_update","parameters":{"properties":{},"type":null}}`)...)
}
body = append(body, []byte(`]}`)...)
b.ReportAllocs()
b.SetBytes(int64(len(body)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = sanitizeOpenAIResponsesToolParameterTypes(body)
}
}
@@ -26,7 +26,7 @@ func setOpenAICodexRoutingHint(headers http.Header, account *Account, model stri
// through a caller- or account-override-supplied hint. http.Header.Del only
// removes the canonical map key; inbound maps can contain raw lowercase keys.
deleteOpenAIHeaderEqualFold(headers, openAICodexRoutingHintHeader)
if account == nil || !account.IsOpenAIOAuth() {
if account == nil || !account.IsOpenAIOAuthLike() {
return
}
@@ -0,0 +1,247 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIsOpenAIOAuthLike(t *testing.T) {
tests := []struct {
name string
account *Account
want bool
codex bool
}{
{name: "openai_oauth", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, want: true, codex: true},
{name: "openai_setup_token", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, want: true, codex: true},
{name: "openai_api_key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: false, codex: false},
{name: "anthropic_setup_token", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, want: false, codex: false},
{name: "grok_setup_token", account: &Account{Platform: PlatformGrok, Type: AccountTypeSetupToken}, want: false, codex: false},
{name: "nil", account: nil, want: false, codex: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, tt.account.IsOpenAIOAuthLike())
require.Equal(t, tt.codex, tt.account.UsesOpenAICodexProtocol())
})
}
}
func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) {
svc := &OpenAIGatewayService{openAITokenProvider: &OpenAITokenProvider{}}
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
Credentials: map[string]any{"access_token": "setup-token-value"},
}
token, tokenType, err := svc.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "setup-token-value", token)
require.Equal(t, "oauth", tokenType)
delete(account.Credentials, "access_token")
_, _, err = svc.GetAccessToken(context.Background(), account)
require.EqualError(t, err, "access_token not found in credentials")
for _, platform := range []string{PlatformAnthropic, PlatformGrok} {
foreign := &Account{
Platform: platform,
Type: AccountTypeSetupToken,
Credentials: map[string]any{"access_token": "foreign-token"},
}
_, _, err = svc.GetAccessToken(context.Background(), foreign)
require.EqualError(t, err, "unsupported account type: setup-token")
}
}
func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 73,
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
Credentials: map[string]any{"access_token": "setup-token"},
}
parsed := &OpenAIImagesRequest{
Endpoint: openAIImagesGenerationsEndpoint,
Model: "gpt-image-2",
Prompt: "draw a square",
N: 1,
ResponseFormat: "b64_json",
}
result, err := svc.ForwardImages(context.Background(), c, account, nil, parsed, "")
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
require.True(t, failoverErr.RetryableOnSameAccount)
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses")
}
func TestOpenAISetupTokenWSCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{}`))
c.Request.Header.Set("session_id", "session-one")
c.Set("api_key", &APIKey{ID: 17})
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
Credentials: map[string]any{
"access_token": "setup-token-value",
"chatgpt_account_id": "chatgpt-setup",
},
}
svc := &OpenAIGatewayService{cfg: &config.Config{}}
wsURL, err := svc.buildOpenAIResponsesWSURL(account)
require.NoError(t, err)
require.Equal(t, "wss://chatgpt.com/backend-api/codex/responses", wsURL)
foreignURL, err := svc.buildOpenAIResponsesWSURL(&Account{Platform: PlatformGrok, Type: AccountTypeSetupToken})
require.NoError(t, err)
require.Equal(t, "wss://api.openai.com/v1/responses", foreignURL)
headers, session, err := svc.buildOpenAIWSHeaders(
context.Background(), c, account, "setup-token-value",
OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
true, "", "", "", "gpt-5.1-codex", "",
)
require.NoError(t, err)
require.Equal(t, "Bearer setup-token-value", headers.Get("authorization"))
require.Equal(t, "chatgpt-setup", headers.Get("chatgpt-account-id"))
require.NotEmpty(t, headers.Get("originator"))
require.Equal(t, "session-one", session.SessionID)
require.NotEqual(t, session.SessionID, headers.Get("session_id"))
payload := svc.buildOpenAIWSCreatePayload(map[string]any{"store": true}, account)
require.Equal(t, false, payload["store"])
}
func TestOpenAISetupTokenChatCompletionsUsesCodexTransform(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"setup instructions"},{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after request capture"}}`)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := openAISetupTokenCompatAccount(71)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
require.Error(t, err)
require.Nil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
require.Equal(t, "Bearer setup-token-value", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "chatgpt-setup", upstream.lastReq.Header.Get("chatgpt-account-id"))
require.NotEmpty(t, upstream.lastReq.Header.Get("originator"))
require.Equal(t, "setup instructions", gjson.GetBytes(upstream.lastBody, "instructions").String())
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
}
func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) {
gin.SetMode(gin.TestMode)
firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4")
firstResp.Header.Set("x-codex-turn-state", "turn_state_setup")
upstream := &httpUpstreamRecorder{responses: []*http.Response{
firstResp,
openAICompatSSECompletedResponse("resp_setup_second", "gpt-5.4"),
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
httpUpstream: upstream,
}
account := openAISetupTokenCompatAccount(72)
messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
}
firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
firstRec := httptest.NewRecorder()
firstCtx, _ := gin.CreateTestContext(firstRec)
firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
firstCtx.Request.Header.Set("Content-Type", "application/json")
firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, firstResult)
require.True(t, isOpenAICompatMessagesBridgeContext(firstCtx))
require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.bodies[0], "input.#").Int())
require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[0], "input.0.role").String())
require.Contains(t, gjson.GetBytes(upstream.bodies[0], "input.0.content.0.text").String(), openAICompatClaudeCodeTodoGuardMarker)
require.Equal(t, "message-00", gjson.GetBytes(upstream.bodies[0], "input.1.content.0.text").String())
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String())
require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization"))
require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex-tui")
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`)
secondRec := httptest.NewRecorder()
secondCtx, _ := gin.CreateTestContext(secondRec)
secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
secondCtx.Request.Header.Set("Content-Type", "application/json")
secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, secondResult)
require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx))
require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui")
}
func openAISetupTokenCompatAccount(id int64) *Account {
return &Account{
ID: id,
Name: "openai-setup-token",
Platform: PlatformOpenAI,
Type: AccountTypeSetupToken,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "setup-token-value",
"chatgpt_account_id": "chatgpt-setup",
},
}
}
@@ -43,6 +43,37 @@ func forEachOpenAISSEDataPayload(body string, fn func([]byte)) {
acc.Flush(fn)
}
func forEachOpenAISSEFrame(body string, fn func(string, []byte)) {
if fn == nil || strings.TrimSpace(body) == "" {
return
}
var parser openAICompatSSEFrameParser
emit := func(frame openAICompatSSEFrame, ok bool) {
if !ok {
return
}
emitData := func(value string) {
value = strings.TrimSpace(value)
if value == "" || value == "[DONE]" {
return
}
data := []byte(value)
fn(effectiveOpenAISSEEventType(data, frame.EventType), data)
}
if gjson.Valid(frame.Data) {
emitData(frame.Data)
return
}
for _, value := range strings.Split(frame.Data, "\n") {
emitData(value)
}
}
for _, line := range strings.Split(body, "\n") {
emit(parser.AddLine(strings.TrimRight(line, "\r")))
}
emit(parser.Finish())
}
func emitOpenAISSEDataPayloads(lines []string, fn func([]byte)) {
if fn == nil || len(lines) == 0 {
return
@@ -183,6 +183,13 @@ func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T
newOpenAIUpstreamErrorResponse(tc.statusCode, tc.body),
c, newOpenAIUpstreamErrorTestAccount(), nil,
)
if tc.name == "forbidden" {
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
require.False(t, c.Writer.Written())
return
}
require.Error(t, err)
require.Equal(t, tc.wantStatus, rec.Code)
@@ -209,7 +209,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *test
require.Nil(t, selection)
}
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored(t *testing.T) {
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_APIKeyForceHTTPHit(t *testing.T) {
ctx := context.Background()
groupID := int64(23)
account := Account{
@@ -239,7 +239,44 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored
selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_force_http", "gpt-5.1", nil, false)
require.NoError(t, err)
require.Nil(t, selection, "force_http 场景应忽略 previous_response_id 粘连")
require.NotNil(t, selection, "API-key HTTP continuation must retain the key/project that created the response")
require.NotNil(t, selection.Account)
require.Equal(t, account.ID, selection.Account.ID)
if selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
}
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_OAuthForceHTTPIgnored(t *testing.T) {
ctx := context.Background()
groupID := int64(23)
account := Account{
ID: 12,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Extra: map[string]any{
"openai_ws_force_http": true,
"responses_websockets_v2_enabled": true,
},
}
cache := &stubGatewayCache{}
store := NewOpenAIWSStateStore(cache)
svc := &OpenAIGatewayService{
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
cache: cache,
cfg: newOpenAIWSV2TestConfig(),
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
openaiWSStateStore: store,
}
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_oauth_force_http", account.ID, time.Hour))
selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_oauth_force_http", "gpt-5.1", nil, false)
require.NoError(t, err)
require.Nil(t, selection, "OAuth HTTP fallback cannot preserve WSv2 continuation state")
}
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(t *testing.T) {
@@ -37,6 +37,18 @@ func TestParseOpenAIWSResponseUsageFromCompletedEvent(t *testing.T) {
require.Equal(t, 19, usage.InputTokens)
require.Equal(t, 5, usage.OutputTokens)
require.Equal(t, 4, usage.CacheReadInputTokens)
parseOpenAIWSResponseUsageFromCompletedEvent(
[]byte(`{"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`),
usage,
)
require.Equal(t, OpenAIUsage{InputTokens: 19, OutputTokens: 5, CacheReadInputTokens: 4}, *usage)
parseOpenAIWSResponseUsageFromCompletedEvent(
[]byte(`{"type":"response.failed","response":{"usage":{"input_tokens":3,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`),
usage,
)
require.Equal(t, OpenAIUsage{InputTokens: 3}, *usage)
}
func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) {
@@ -54,7 +66,11 @@ func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) {
require.True(t, openAIWSEventShouldParseUsage(" "+eventType+" "), eventType)
}
require.False(t, openAIWSEventShouldParseUsage("response.output_text.delta"))
require.True(t, openAIWSEventShouldParseUsage("response.output_text.done"))
require.False(t, openAIWSEventShouldParseUsage(""))
require.False(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress"}`)))
require.True(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress","usage":{}}`)))
require.False(t, openAIWSMessageShouldParseUsage("response.output_text.delta", []byte(`{"type":"response.output_text.delta","usage":{}}`)))
}
func TestOpenAIWSErrorEventHelpers_ConsistentWithWrapper(t *testing.T) {
@@ -18,6 +18,28 @@ import (
"github.com/tidwall/sjson"
)
type openAIWSRejectedFieldRetryError struct {
body []byte
reason string
}
func (e *openAIWSRejectedFieldRetryError) Error() string {
if e == nil || strings.TrimSpace(e.reason) == "" {
return "retry websocket turn after rejected field normalization"
}
return "retry websocket turn after rejected field normalization: " + e.reason
}
func openAIWSRejectedFieldRetryHTTPStatus(message []byte) int {
for _, value := range gjson.GetManyBytes(message, "status", "status_code", "error.status", "error.status_code") {
status := int(value.Int())
if status >= 100 && status <= 599 {
return status
}
}
return openAIWSErrorHTTPStatus(message)
}
func (s *OpenAIGatewayService) openAIWSIngressInterTurnIdleTimeout() time.Duration {
if s == nil || s.cfg == nil || s.cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds <= 0 {
return 0
@@ -48,7 +70,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
token string,
firstClientMessage []byte,
hooks *OpenAIWSIngressHooks,
) error {
) (returnErr error) {
if s == nil {
return errors.New("service is nil")
}
@@ -61,6 +83,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if account == nil {
return errors.New("account is nil")
}
// A handler may reuse the same gin context across account failover attempts.
// Never let an OAuth attempt's response aliases leak into the next account.
setCodexToolNameReverse(c, nil)
if err := validateOpenAIWSBearerToken(account, token); err != nil {
return err
}
@@ -74,6 +99,37 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
}
// Only persistent inbound WebSocket sessions participate in preemption.
// HTTP ingress that opportunistically uses an upstream WS is handled by
// forwardOpenAIWSV2 and deliberately never reaches this registration.
preemptSessionHash := ""
preemptGroupID := getOpenAIGroupIDFromContext(c)
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
preemptSessionHash = s.GenerateSessionHash(c, firstClientMessage)
}
if preemptCtx, cleanupPreempt, armed, preemptedPrevious := s.beginOpenAIWSSessionPreemptContext(
ctx,
account,
preemptGroupID,
getAPIKeyIDFromContext(c),
preemptSessionHash,
false,
); armed {
ctx = preemptCtx
defer cleanupPreempt()
defer func() {
if isOpenAIWSSessionPreempted(ctx) {
returnErr = errOpenAIWSSessionPreempted
}
}()
if preemptedPrevious {
if stateStore := s.getOpenAIWSStateStore(); stateStore != nil {
stateStore.DeleteSessionTurnState(preemptGroupID, preemptSessionHash)
stateStore.DeleteSessionConn(preemptGroupID, preemptSessionHash)
}
}
}
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
forceHTTPBridge := account.Platform == PlatformGrok
modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled
@@ -225,12 +281,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
} else if compatibilityChanged {
normalized = compatibilityBody
}
if account.IsOpenAIOAuth() && !forceHTTPBridge {
if account.IsOpenAIOAuthLike() {
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(normalized)
if aliasErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr)
}
setCodexToolNameReverse(c, reverse)
updateCodexToolNameReverseForWSFrame(c, normalized, reverse)
if aliased {
normalized = aliasedBody
}
@@ -270,7 +326,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
normalized = next
}
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
if liteErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
@@ -294,7 +350,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
if codexBridgeEnabled {
payloadMap := make(map[string]any)
if err := json.Unmarshal(normalized, &payloadMap); err != nil {
if err := decodeOpenAIJSONUseNumber(normalized, &payloadMap); err != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", err)
}
bridgeModified := false
@@ -339,6 +395,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
normalized = next
}
SetOpsUpstreamModel(c, upstreamModel)
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
if stripped, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(normalized); stripErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr)
@@ -594,6 +651,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
turn,
writeClientMessage,
)
if bridgeErr != nil && isOpenAIWSSessionPreempted(ctx) {
return errOpenAIWSSessionPreempted
}
if hooks != nil && hooks.AfterTurn != nil {
hooks.AfterTurn(turn, result, bridgeErr)
}
@@ -647,6 +707,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
nextClientMessage, readErr := readClientMessage()
if readErr != nil {
if isOpenAIWSSessionPreempted(ctx) {
return errOpenAIWSSessionPreempted
}
if isOpenAIWSClientDisconnectError(readErr) {
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
logOpenAIWSModeInfo(
@@ -774,6 +837,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return acquireTurnLease(turn, preferred, forcePreferredConn)
}
if acquireErr != nil {
if isOpenAIWSSessionPreempted(ctx) {
return nil, errOpenAIWSSessionPreempted
}
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, ingressSessionOriginalModel)
s.handleOpenAIWSDialTransientFailure(ctx, account, canonicalModel, acquireErr)
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr)
@@ -800,10 +866,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
var dialErr *openAIWSDialError
if errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests {
s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(acquireErr.Error()))
return nil, &UpstreamFailoverError{
StatusCode: http.StatusTooManyRequests,
ResponseHeaders: cloneHeader(dialErr.ResponseHeaders),
}
return nil, s.newOpenAIWSRateLimitFailoverError(account, dialErr.ResponseHeaders, nil, acquireErr.Error())
}
if errors.Is(acquireErr, errOpenAIWSPreferredConnUnavailable) {
return nil, NewOpenAIWSClientCloseError(
@@ -847,6 +910,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return lease, nil
}
var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) {
responseModelObserver := &upstreamResponseModelObserver{}
if lease == nil {
@@ -928,9 +992,32 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
lastEventType = eventType
}
if eventType == "error" {
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage)
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage)
statusCode := openAIWSRejectedFieldRetryHTTPStatus(upstreamMessage)
if !wroteDownstream && statusCode == http.StatusBadRequest && rejectedFieldRetryState != nil {
retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(
statusCode,
payload,
upstreamMessage,
)
if retryErr != nil {
return nil, fmt.Errorf("normalize websocket rejected field retry: %w", retryErr)
}
if changed && rejectedFieldRetryState.Allow(retryBody) {
logOpenAIWSModeInfo(
"ingress_ws_rejected_field_retry account_id=%d turn=%d conn_id=%s reason=%s",
account.ID,
turn,
truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen),
truncateOpenAIWSLogValue(retryReason, openAIWSLogValueMaxLen),
)
return nil, &openAIWSRejectedFieldRetryError{
body: append([]byte(nil), retryBody...),
reason: retryReason,
}
}
}
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw)
fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
@@ -991,11 +1078,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
if !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) {
lease.MarkBroken()
return nil, &UpstreamFailoverError{
StatusCode: http.StatusTooManyRequests,
ResponseBody: append([]byte(nil), upstreamMessage...),
ResponseHeaders: cloneHeader(lease.HandshakeHeaders()),
}
return nil, s.newOpenAIWSRateLimitFailoverError(account, lease.HandshakeHeaders(), upstreamMessage, errMsgRaw)
}
}
isTokenEvent := isOpenAIWSTokenEvent(eventType)
@@ -1010,7 +1093,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
ms := int(time.Since(turnStart).Milliseconds())
firstTokenMs = &ms
}
if openAIWSEventShouldParseUsage(eventType) {
if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) {
parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage)
}
imageCounter.AddSSEData(upstreamMessage)
@@ -1027,7 +1110,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
})
}
}
if !clientDisconnected {
if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) {
upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel)
@@ -1059,11 +1141,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
} else {
wroteDownstream = true
markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage)
}
}
if isTerminalEvent {
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage)
// 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。
if clientDisconnected {
lease.MarkBroken()
@@ -1185,6 +1267,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
defer releaseSessionLease()
turn := 1
rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload)
turnRetry := 0
turnPrevRecoveryTried := false
lastTurnFinishedAt := time.Time{}
@@ -1618,6 +1701,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize)
if relayErr != nil {
lastTurnClean = false
if isOpenAIWSSessionPreempted(ctx) {
sessionLease.MarkBroken()
return errOpenAIWSSessionPreempted
}
var rejectedFieldErr *openAIWSRejectedFieldRetryError
if errors.As(relayErr, &rejectedFieldErr) && rejectedFieldErr != nil && len(rejectedFieldErr.body) > 0 {
currentPayload = append([]byte(nil), rejectedFieldErr.body...)
currentPayloadBytes = len(currentPayload)
skipBeforeTurn = true
continue
}
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
continue
}
@@ -1681,6 +1775,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
nextClientMessage, readErr := readClientMessage()
if readErr != nil {
if isOpenAIWSSessionPreempted(ctx) {
return errOpenAIWSSessionPreempted
}
if isOpenAIWSClientDisconnectError(readErr) {
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
logOpenAIWSModeInfo(
@@ -1763,6 +1860,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
currentImageSizeTier = nextPayload.imageSizeTier
currentImageInputSize = nextPayload.imageInputSize
currentPayloadBytes = nextPayload.payloadBytes
rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload)
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
if !storeDisabled {
unpinSessionConn(sessionConnID)
@@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
}()
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat"}`))
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","sequence":900719925474099312345}`))
cancelWrite()
require.NoError(t, err)
@@ -803,6 +803,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String())
require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation")
require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists())
require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw)
litePayload := requestToJSONString(captureConn.writes[1])
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
@@ -161,12 +161,15 @@ func openAIWSEventMayContainToolCalls(eventType string) bool {
}
func openAIWSEventShouldParseUsage(eventType string) bool {
switch strings.TrimSpace(eventType) {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
eventType = strings.TrimSpace(eventType)
if eventType == "error" || openAIStreamEventTypeIsTerminal(eventType) {
return true
default:
return false
}
return strings.HasPrefix(eventType, "response.") && !strings.HasSuffix(eventType, ".delta")
}
func openAIWSMessageShouldParseUsage(eventType string, message []byte) bool {
return openAIWSEventShouldParseUsage(eventType) && bytes.Contains(message, []byte(`"usage"`))
}
func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) {
@@ -193,11 +196,18 @@ func openAIWSMessageLikelyContainsToolCalls(message []byte) bool {
}
func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) {
if usage == nil || len(message) == 0 {
if usage == nil || len(message) == 0 || !bytes.Contains(message, []byte(`"usage"`)) {
return
}
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok {
*usage = parsedUsage
if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(message, "")) {
if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) {
return
}
*usage = parsedUsage
} else {
mergeOpenAIUsageNonZero(usage, parsedUsage)
}
}
}

Some files were not shown because too many files have changed in this diff Show More