diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index 7c529fc8cb..4a8b620905 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -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 { diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 0836e22d64..b52f873627 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -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) diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index c05cbded41..f67e92b263 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -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] diff --git a/backend/internal/repository/gateway_cache_cyber_test.go b/backend/internal/repository/gateway_cache_cyber_test.go new file mode 100644 index 0000000000..40396e7c26 --- /dev/null +++ b/backend/internal/repository/gateway_cache_cyber_test.go @@ -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")) +} diff --git a/backend/internal/repository/temp_unsched_cache.go b/backend/internal/repository/temp_unsched_cache.go index 55115eb835..c145a30476 100644 --- a/backend/internal/repository/temp_unsched_cache.go +++ b/backend/internal/repository/temp_unsched_cache.go @@ -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() +} diff --git a/backend/internal/repository/temp_unsched_cache_health_test.go b/backend/internal/repository/temp_unsched_cache_health_test.go new file mode 100644 index 0000000000..092b85ac6b --- /dev/null +++ b/backend/internal/repository/temp_unsched_cache_health_test.go @@ -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) + } +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd66ef89ce..8699f595a3 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ba35301be0..599f25777d 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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 diff --git a/backend/internal/service/error_policy_test.go b/backend/internal/service/error_policy_test.go index 147143e8dd..de3d03300b 100644 --- a/backend/internal/service/error_policy_test.go +++ b/backend/internal/service/error_policy_test.go @@ -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{ diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 2aa5255c29..a7cff0ebe4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -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 { diff --git a/backend/internal/service/gateway_forward_partial_usage_test.go b/backend/internal/service/gateway_forward_partial_usage_test.go index 2b7666378e..90f6757e01 100644 --- a/backend/internal/service/gateway_forward_partial_usage_test.go +++ b/backend/internal/service/gateway_forward_partial_usage_test.go @@ -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") } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 2578d59b1d..6a2e2930cd 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 diff --git a/backend/internal/service/grok_upstream_errors.go b/backend/internal/service/grok_upstream_errors.go index 0039ea4cdc..918bea6613 100644 --- a/backend/internal/service/grok_upstream_errors.go +++ b/backend/internal/service/grok_upstream_errors.go @@ -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. diff --git a/backend/internal/service/openai_access_state_failover_test.go b/backend/internal/service/openai_access_state_failover_test.go new file mode 100644 index 0000000000..0acaa26182 --- /dev/null +++ b/backend/internal/service/openai_access_state_failover_test.go @@ -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)) + }) + } +} diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 6853cc8487..54c73d1a0e 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -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 } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 09d4662468..97bfae0eee 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -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)) diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 140e15d490..4ba1f16a55 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -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")) } diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 1daf81988c..16ebde7b62 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -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) diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index cd914da0f8..62dde1ee8c 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -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) diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index c49ecead94..1954dc2670 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -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() diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index ab3f9145bf..b5d37d1dd8 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -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"}]}}`) diff --git a/backend/internal/service/openai_apikey_health_breaker.go b/backend/internal/service/openai_apikey_health_breaker.go new file mode 100644 index 0000000000..c898cf1124 --- /dev/null +++ b/backend/internal/service/openai_apikey_health_breaker.go @@ -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)) + } +} diff --git a/backend/internal/service/openai_apikey_health_breaker_test.go b/backend/internal/service/openai_apikey_health_breaker_test.go new file mode 100644 index 0000000000..a6e7aaad00 --- /dev/null +++ b/backend/internal/service/openai_apikey_health_breaker_test.go @@ -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) +} diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index 3f4f273055..ac32fa5749 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -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") diff --git a/backend/internal/service/openai_chatgpt_headers.go b/backend/internal/service/openai_chatgpt_headers.go index fe1cd5947e..a9ad42673c 100644 --- a/backend/internal/service/openai_chatgpt_headers.go +++ b/backend/internal/service/openai_chatgpt_headers.go @@ -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 != "" { diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 0f841fb2fe..1e19c07bda 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -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) diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index 841ee3437d..1fdd06de1f 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -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}, diff --git a/backend/internal/service/openai_codex_tool_names.go b/backend/internal/service/openai_codex_tool_names.go index 243757c113..dfa8deacc0 100644 --- a/backend/internal/service/openai_codex_tool_names.go +++ b/backend/internal/service/openai_codex_tool_names.go @@ -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 } diff --git a/backend/internal/service/openai_codex_tool_names_test.go b/backend/internal/service/openai_codex_tool_names_test.go index 25f8248b48..d8981dce98 100644 --- a/backend/internal/service/openai_codex_tool_names_test.go +++ b/backend/internal/service/openai_codex_tool_names_test.go @@ -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()) +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 3b9cafd946..c929e29090 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -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 } diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index adeffadbfa..7b1606f76e 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -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) diff --git a/backend/internal/service/openai_compact_body_signal.go b/backend/internal/service/openai_compact_body_signal.go index 09d962529d..c2aaf870f9 100644 --- a/backend/internal/service/openai_compact_body_signal.go +++ b/backend/internal/service/openai_compact_body_signal.go @@ -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) { diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go index 3b0a21295e..db8b6b404b 100644 --- a/backend/internal/service/openai_compact_body_signal_test.go +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -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()) +} diff --git a/backend/internal/service/openai_compact_fallback.go b/backend/internal/service/openai_compact_fallback.go new file mode 100644 index 0000000000..79595adbb6 --- /dev/null +++ b/backend/internal/service/openai_compact_fallback.go @@ -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 +} diff --git a/backend/internal/service/openai_compact_fallback_test.go b/backend/internal/service/openai_compact_fallback_test.go new file mode 100644 index 0000000000..377dea2212 --- /dev/null +++ b/backend/internal/service/openai_compact_fallback_test.go @@ -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") +} diff --git a/backend/internal/service/openai_compact_model_mapping_test.go b/backend/internal/service/openai_compact_model_mapping_test.go index fc408e64af..f03fca1cd6 100644 --- a/backend/internal/service/openai_compact_model_mapping_test.go +++ b/backend/internal/service/openai_compact_model_mapping_test.go @@ -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) } diff --git a/backend/internal/service/openai_compact_stream_bridge_test.go b/backend/internal/service/openai_compact_stream_bridge_test.go index bf3416192e..3a095a6367 100644 --- a/backend/internal/service/openai_compact_stream_bridge_test.go +++ b/backend/internal/service/openai_compact_stream_bridge_test.go @@ -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) diff --git a/backend/internal/service/openai_cyber_session_block.go b/backend/internal/service/openai_cyber_session_block.go index ebca8b1967..797e746561 100644 --- a/backend/internal/service/openai_cyber_session_block.go +++ b/backend/internal/service/openai_cyber_session_block.go @@ -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 } diff --git a/backend/internal/service/openai_cyber_session_block_test.go b/backend/internal/service/openai_cyber_session_block_test.go index 66ee99fa77..7080450042 100644 --- a/backend/internal/service/openai_cyber_session_block_test.go +++ b/backend/internal/service/openai_cyber_session_block_test.go @@ -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")) } diff --git a/backend/internal/service/openai_cyber_transcript.go b/backend/internal/service/openai_cyber_transcript.go new file mode 100644 index 0000000000..a276a4315b --- /dev/null +++ b/backend/internal/service/openai_cyber_transcript.go @@ -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 + } +} diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index e68017a453..ef0c88b2d7 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -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) diff --git a/backend/internal/service/openai_embeddings_test.go b/backend/internal/service/openai_embeddings_test.go index c7e89d6449..1e79dae5dd 100644 --- a/backend/internal/service/openai_embeddings_test.go +++ b/backend/internal/service/openai_embeddings_test.go @@ -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()) +} diff --git a/backend/internal/service/openai_failover_cached_body_test.go b/backend/internal/service/openai_failover_cached_body_test.go index 709032d27e..1e1cd0e9d1 100644 --- a/backend/internal/service/openai_failover_cached_body_test.go +++ b/backend/internal/service/openai_failover_cached_body_test.go @@ -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) { diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index f624a0080b..3f3883e278 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -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 diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go index 9c4990f545..9605a0acee 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -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)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index ce9dd54d37..bfd421bd53 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -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)), ) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 3cd4a99bd9..86ecd62a47 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 3c46194188..f51bf00c8a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -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 diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index ededa9f322..e54e2de635 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 0562ab92a7..876dcf7d91 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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)) } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index d9c7c5b0e4..9e2c70579b 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_grok_model_input.go b/backend/internal/service/openai_gateway_grok_model_input.go new file mode 100644 index 0000000000..ae6af5b498 --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_model_input.go @@ -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 +} diff --git a/backend/internal/service/openai_gateway_grok_model_input_test.go b/backend/internal/service/openai_gateway_grok_model_input_test.go new file mode 100644 index 0000000000..4696c6feed --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_model_input_test.go @@ -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)) +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 7b30e683aa..3c93776d4b 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 914db873f0..8800b335b5 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_messages_failed_response_test.go b/backend/internal/service/openai_gateway_messages_failed_response_test.go index 51d5fcb7c9..6cb8827655 100644 --- a/backend/internal/service/openai_gateway_messages_failed_response_test.go +++ b/backend/internal/service/openai_gateway_messages_failed_response_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 98aadf1bfd..8f44e8bac0 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_passthrough_flush_test.go b/backend/internal/service/openai_gateway_passthrough_flush_test.go index b36a037806..9ca59bee22 100644 --- a/backend/internal/service/openai_gateway_passthrough_flush_test.go +++ b/backend/internal/service/openai_gateway_passthrough_flush_test.go @@ -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" + diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 980788284a..105c3422cb 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -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 diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index dc3f2d719d..f14a75018b 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -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()) +} diff --git a/backend/internal/service/openai_gateway_response_failed_passthrough_test.go b/backend/internal/service/openai_gateway_response_failed_passthrough_test.go index f9439c0551..bf62106aac 100644 --- a/backend/internal/service/openai_gateway_response_failed_passthrough_test.go +++ b/backend/internal/service/openai_gateway_response_failed_passthrough_test.go @@ -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) diff --git a/backend/internal/service/openai_gateway_response_flush_test.go b/backend/internal/service/openai_gateway_response_flush_test.go index ebfa0f22f1..2b07992c32 100644 --- a/backend/internal/service/openai_gateway_response_flush_test.go +++ b/backend/internal/service/openai_gateway_response_flush_test.go @@ -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 diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index bc6043fdc9..779ade3cee 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -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 } diff --git a/backend/internal/service/openai_gateway_response_handling_type_test.go b/backend/internal/service/openai_gateway_response_handling_type_test.go index b783ab5c79..579224e268 100644 --- a/backend/internal/service/openai_gateway_response_handling_type_test.go +++ b/backend/internal/service/openai_gateway_response_handling_type_test.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 084f9e5020..22c9278208 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -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) diff --git a/backend/internal/service/openai_gateway_responses_client_tools_test.go b/backend/internal/service/openai_gateway_responses_client_tools_test.go index cfb4f6eb62..2189993d6e 100644 --- a/backend/internal/service/openai_gateway_responses_client_tools_test.go +++ b/backend/internal/service/openai_gateway_responses_client_tools_test.go @@ -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()) diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 7dd5414724..8ecdd45eb4 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -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) { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 16082a16a4..742cd732e2 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -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")) diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index a51f58bf56..bc066b12d8 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -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()) } } diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index ddaed2a8df..8c1bdc293a 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -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() diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index 3fa2c3db75..62add58e24 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -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) diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index edd3fc92fc..c29af50016 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -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) } diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 4656d22c4a..79680e66eb 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -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), + ) } diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index a1bf5caf7b..cb2fdf1b5a 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -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"}`) diff --git a/backend/internal/service/openai_json_decode.go b/backend/internal/service/openai_json_decode.go new file mode 100644 index 0000000000..d0366f06af --- /dev/null +++ b/backend/internal/service/openai_json_decode.go @@ -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 +} diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 7107a706ad..75e8a07463 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -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", diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index c7dc77b3aa..fb9a6fee27 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -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) diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index e7d06e4b8f..ac5cb87c71 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -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 diff --git a/backend/internal/service/openai_response_terminal_usage_compat_test.go b/backend/internal/service/openai_response_terminal_usage_compat_test.go new file mode 100644 index 0000000000..f6dc899e09 --- /dev/null +++ b/backend/internal/service/openai_response_terminal_usage_compat_test.go @@ -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) +} diff --git a/backend/internal/service/openai_responses_ingress_compat.go b/backend/internal/service/openai_responses_ingress_compat.go new file mode 100644 index 0000000000..13c6168c7a --- /dev/null +++ b/backend/internal/service/openai_responses_ingress_compat.go @@ -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) + } +} diff --git a/backend/internal/service/openai_responses_ingress_compat_test.go b/backend/internal/service/openai_responses_ingress_compat_test.go new file mode 100644 index 0000000000..3e898f03e6 --- /dev/null +++ b/backend/internal/service/openai_responses_ingress_compat_test.go @@ -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()) +} diff --git a/backend/internal/service/openai_responses_input_compat.go b/backend/internal/service/openai_responses_input_compat.go index 6e01e28d85..e8c4a8116d 100644 --- a/backend/internal/service/openai_responses_input_compat.go +++ b/backend/internal/service/openai_responses_input_compat.go @@ -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 } diff --git a/backend/internal/service/openai_responses_input_compat_test.go b/backend/internal/service/openai_responses_input_compat_test.go index 86a8841521..063148ec13 100644 --- a/backend/internal/service/openai_responses_input_compat_test.go +++ b/backend/internal/service/openai_responses_input_compat_test.go @@ -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()) } diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go index 62a990afbb..f4621aa395 100644 --- a/backend/internal/service/openai_responses_item_id.go +++ b/backend/internal/service/openai_responses_item_id.go @@ -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, ',') } diff --git a/backend/internal/service/openai_responses_item_id_test.go b/backend/internal/service/openai_responses_item_id_test.go new file mode 100644 index 0000000000..17a0b2f9bc --- /dev/null +++ b/backend/internal/service/openai_responses_item_id_test.go @@ -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()) + } +} diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index 73760f567b..f4fd47b13b 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -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 { diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 80095f3293..7a9b47b7f5 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -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}, } diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index 592d1ac31a..20affff9cf 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -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())) diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 4929ac3a0c..96b118050c 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -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 diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go index 5063785066..b4d4e261ca 100644 --- a/backend/internal/service/openai_responses_tool_schema.go +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -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, "(? 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 } diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index 89ed646489..eeb9cf5dab 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -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":"(? 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) + } +} diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go index 0a1cc928cd..85fd93314d 100644 --- a/backend/internal/service/openai_routing_hint.go +++ b/backend/internal/service/openai_routing_hint.go @@ -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 } diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go new file mode 100644 index 0000000000..7405deb419 --- /dev/null +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -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", + }, + } +} diff --git a/backend/internal/service/openai_sse_data.go b/backend/internal/service/openai_sse_data.go index 61b813b601..3fdb70f8f4 100644 --- a/backend/internal/service/openai_sse_data.go +++ b/backend/internal/service/openai_sse_data.go @@ -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 diff --git a/backend/internal/service/openai_upstream_client_error_test.go b/backend/internal/service/openai_upstream_client_error_test.go index b9f5b3795a..2b6ed51967 100644 --- a/backend/internal/service/openai_upstream_client_error_test.go +++ b/backend/internal/service/openai_upstream_client_error_test.go @@ -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) diff --git a/backend/internal/service/openai_ws_account_sticky_test.go b/backend/internal/service/openai_ws_account_sticky_test.go index 6fc44298a4..3d0035eddb 100644 --- a/backend/internal/service/openai_ws_account_sticky_test.go +++ b/backend/internal/service/openai_ws_account_sticky_test.go @@ -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) { diff --git a/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go b/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go index 2622f7f201..945986c8c7 100644 --- a/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go +++ b/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go @@ -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) { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 3cd532260b..81e5847fa5 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -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) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 6715a01ae4..868da4776d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -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()) diff --git a/backend/internal/service/openai_ws_forwarder_logutil.go b/backend/internal/service/openai_ws_forwarder_logutil.go index 04d1fb4a75..ac5a3720b1 100644 --- a/backend/internal/service/openai_ws_forwarder_logutil.go +++ b/backend/internal/service/openai_ws_forwarder_logutil.go @@ -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) + } } } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 3d6fef10be..aa51a1846d 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -33,6 +33,12 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri switch account.Type { case AccountTypeOAuth: targetURL = chatgptCodexURL + case AccountTypeSetupToken: + if account.IsOpenAIOAuthLike() { + targetURL = chatgptCodexURL + } else { + targetURL = openaiPlatformAPIURL + } case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() if baseURL == "" { @@ -112,7 +118,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( // 实际请求因头差异落进不同的连接池兼容分桶。 applyOpenAICodexBetaFeatures(c, account, headers) // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { apiKeyID := getAPIKeyIDFromContext(c) if sessionResolution.SessionID != "" { headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) @@ -136,7 +142,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } applyStagedCodexFingerprintHeaders(c, account, headers) - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) } @@ -165,7 +171,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account)) } @@ -201,7 +207,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any payload["type"] = "response.create" // OAuth 默认保持 store=false,避免误依赖服务端历史。 - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { payload["store"] = false } return payload @@ -245,7 +251,7 @@ func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) } func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { return true } if len(reqBody) == 0 { @@ -263,7 +269,7 @@ func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[stri } func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { return true } if len(reqBody) == 0 { @@ -356,7 +362,7 @@ func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string } var reqBody map[string]any - if unmarshalErr := json.Unmarshal(payload, &reqBody); unmarshalErr != nil { + if unmarshalErr := decodeOpenAIJSONUseNumber(payload, &reqBody); unmarshalErr != nil { return nil, err } reqBody["previous_response_id"] = normalizedPrevID @@ -450,7 +456,7 @@ func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { return nil, errors.New("json is empty") } var decoded any - if err := json.Unmarshal(trimmed, &decoded); err != nil { + if err := decodeOpenAIJSONUseNumber(trimmed, &decoded); err != nil { return nil, err } return json.Marshal(decoded) @@ -469,7 +475,7 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( return nil, errors.New("payload is empty") } var decoded map[string]any - if err := json.Unmarshal(payload, &decoded); err != nil { + if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil { return nil, err } delete(decoded, "input") diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 2c69776382..24f23f0d2b 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -1902,13 +1902,13 @@ func (c *openAIWSCaptureConn) WriteJSON(ctx context.Context, value any) error { c.writes = append(c.writes, cloneMapStringAny(payload)) case json.RawMessage: var parsed map[string]any - if err := json.Unmarshal(payload, &parsed); err == nil { + if err := decodeOpenAIJSONUseNumber(payload, &parsed); err == nil { c.lastWrite = cloneMapStringAny(parsed) c.writes = append(c.writes, cloneMapStringAny(parsed)) } case []byte: var parsed map[string]any - if err := json.Unmarshal(payload, &parsed); err == nil { + if err := decodeOpenAIJSONUseNumber(payload, &parsed); err == nil { c.lastWrite = cloneMapStringAny(parsed) c.writes = append(c.writes, cloneMapStringAny(parsed)) } diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 6c6d7c9cf3..11f54d1568 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -221,6 +221,49 @@ func normalizeOpenAIWSTerminalEvent(eventType string) string { } } +// markOpenAIWSClientVisibleFailure records only terminal/error protocol events +// that were delivered to the client. Callers invoke it only after any hidden +// failover/recovery decision and a successful downstream write. +func markOpenAIWSClientVisibleFailure(c *gin.Context, eventType string, payload []byte) { + eventType = strings.TrimSpace(eventType) + if eventType != "error" && eventType != "response.failed" { + return + } + prefix := "error" + if eventType == "response.failed" { + prefix = "response.error" + } + code := strings.TrimSpace(gjson.GetBytes(payload, prefix+".code").String()) + errType := strings.TrimSpace(gjson.GetBytes(payload, prefix+".type").String()) + message := strings.TrimSpace(gjson.GetBytes(payload, prefix+".message").String()) + if eventType == "response.failed" && code == "" && errType == "" && message == "" { + prefix = "error" + code = strings.TrimSpace(gjson.GetBytes(payload, prefix+".code").String()) + errType = strings.TrimSpace(gjson.GetBytes(payload, prefix+".type").String()) + message = strings.TrimSpace(gjson.GetBytes(payload, prefix+".message").String()) + } + status := int(gjson.GetBytes(payload, prefix+".status_code").Int()) + if status == 0 { + status = int(gjson.GetBytes(payload, prefix+".status").Int()) + } + if status == 0 && eventType == "error" { + status = int(gjson.GetBytes(payload, "status").Int()) + } + if status == 0 { + status = openAIWSErrorHTTPStatusFromRaw(code, errType) + } + if errType == "" { + errType = "upstream_error" + } + if code == "" { + code = strings.ReplaceAll(eventType, ".", "_") + } + if message == "" { + message = "upstream websocket request failed" + } + MarkOpsStreamFailure(c, errType, code, message, status) +} + func openAIWSPayloadTransientStatus(payload []byte) int { if len(payload) == 0 { return 0 @@ -270,10 +313,7 @@ func (s *OpenAIGatewayService) handleOpenAIWSTerminalTransientFailure(ctx contex if terminalEvent != "response.failed" { return terminalEvent } - status := openAIWSPayloadTransientStatus(payload) - if status != 0 { - s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) - } + s.handleOpenAIWSFailureAccountSideEffects(ctx, account, canonicalModel, headers, payload) return terminalEvent } @@ -288,6 +328,32 @@ func (s *OpenAIGatewayService) handleOpenAIWSErrorEventTransientFailure(ctx cont } } +// handleOpenAIWSFailureAccountSideEffects applies both structured credential +// failures and transient failures. Its return value lets stream callers avoid +// applying the same transition twice for an error/response.failed pair. +func (s *OpenAIGatewayService) handleOpenAIWSFailureAccountSideEffects(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) bool { + message := extractOpenAISSEErrorMessage(payload) + status := openAIStreamFailureStatus(payload, message) + switch status { + case http.StatusUnauthorized, http.StatusTooManyRequests, 529: + s.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, message, headers) + return true + case http.StatusForbidden: + if !openAIStream403AccountFailure(payload, message) { + return false + } + s.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, message, headers) + return true + } + + status = openAIWSPayloadTransientStatus(payload) + if status == 0 { + return false + } + s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) + return true +} + func (s *OpenAIGatewayService) handleOpenAIWSDialTransientFailure(ctx context.Context, account *Account, canonicalModel string, err error) { var dialErr *openAIWSDialError if !errors.As(err, &dialErr) || dialErr == nil || !shouldCooldownOpenAITransientUpstreamError(dialErr.StatusCode, dialErr.ResponseBody) { @@ -486,9 +552,11 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil } - // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, - // 以保持“回滚到 HTTP”后的历史行为一致性。 - if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + // OAuth/SetupToken continuation state lives on the WSv2 session and cannot + // survive an HTTP fallback. Official API-key Responses HTTP requests are + // different: previous_response_id is supported by the provider and scoped to + // the selected key/project, so the response-id binding must retain that key. + if !account.IsOpenAIApiKey() && s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { return 0, nil, "", nil } if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { @@ -619,6 +687,18 @@ func (s *OpenAIGatewayService) persistOpenAIWSRateLimitSignal(ctx context.Contex s.handleOpenAIAccountUpstreamError(ctx, account, http.StatusTooManyRequests, headers, responseBody) } +func (s *OpenAIGatewayService) newOpenAIWSRateLimitFailoverError(account *Account, headers http.Header, responseBody []byte, message string) *UpstreamFailoverError { + return s.newOpenAIAccountFailoverError( + account, + http.StatusTooManyRequests, + headers, + responseBody, + strings.TrimSpace(message), + false, + false, + ) +} + func classifyOpenAIWSErrorEventFromRaw(codeRaw, errTypeRaw, msgRaw string) (string, bool) { code := strings.ToLower(strings.TrimSpace(codeRaw)) errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 178253e376..4ecd73d09a 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -574,7 +574,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } message = restoreCodexToolNamesFromContext(c, message) } - if openAIWSEventShouldParseUsage(eventType) { + if openAIWSMessageShouldParseUsage(eventType, message) { parseOpenAIWSResponseUsageFromCompletedEvent(message, usage) } imageCounter.AddSSEData(message) @@ -692,6 +692,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } if isTerminalEvent { + if !clientDisconnected { + markOpenAIWSClientVisibleFailure(c, eventType, message) + } upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message) // A terminal event must be the final JSON document in its WS message. // Ignore any tail for the completed client turn, but never reuse the diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 1fab9be1a5..1580b287b1 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -91,7 +91,7 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { var body map[string]any - if err := json.Unmarshal(payload, &body); err != nil { + if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil { return nil, err } if body == nil { @@ -213,6 +213,43 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte { return body } +func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte, fallbackMessage string) []byte { + 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 := map[string]any{"code": code, "message": message} + if errorType != "" { + errorBody["type"] = errorType + } + response := map[string]any{ + "id": responseID, "object": "response", "status": "failed", + "output": []any{}, "error": errorBody, + } + if model = strings.TrimSpace(model); model != "" { + response["model"] = model + } + body, err := json.Marshal(map[string]any{"type": "response.failed", "response": response}) + if err != nil { + return []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`) + } + return body +} + func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( ctx context.Context, c *gin.Context, @@ -273,38 +310,46 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( }) } - upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - var upstreamReq *http.Request + buildUpstreamRequest := func(requestBody []byte) (*http.Request, error) { + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + defer releaseUpstreamCtx() + var upstreamReq *http.Request + var buildErr error + if account.Platform == PlatformGrok { + upstreamReq, buildErr = buildGrokResponsesRequest(upstreamCtx, c, account, requestBody, token, grokCacheIdentity, s.cfg, s.settingService) + } else { + upstreamReq, buildErr = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, requestBody, token) + } + if buildErr != nil { + return nil, buildErr + } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + upstreamReq.Header.Set(responsesLiteHeader, "true") + } + return upstreamReq, nil + } if account.Platform == PlatformGrok { upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) grokIntentSourceBody := body body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { - releaseUpstreamCtx() return nil, err } grokMixedCacheIntentBody := append([]byte(nil), body...) body, err = applyGrokResponsesCacheIdentity(body, grokIntentSourceBody, grokCacheIdentity, account.IsGrokOAuth()) if err != nil { - releaseUpstreamCtx() return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } body, err = applyGrokFreeRequestToolCacheRoute(c, body, grokMixedCacheIntentBody, account, grokCacheIdentity) if err != nil { - releaseUpstreamCtx() return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) } - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg, s.settingService) - } else { - upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) } - releaseUpstreamCtx() - if err != nil { - return nil, err - } - if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { - upstreamReq.Header.Set(responsesLiteHeader, "true") + actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if actualModel == "" { + actualModel = canonicalOpenAIAccountSchedulingModel(account, originalModel) } + SetOpsUpstreamModel(c, actualModel) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { @@ -316,19 +361,47 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } turnStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - if err != nil { - if turn == 1 { - return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body) + var resp *http.Response + for { + upstreamReq, buildErr := buildUpstreamRequest(body) + if buildErr != nil { + return nil, buildErr + } + resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + if turn == 1 { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + clientError := buildOpenAIWSHTTPBridgeErrorEvent(http.StatusBadGateway, "Upstream request failed") + if writeErr := writeClientMessage(clientError); writeErr == nil { + markOpenAIWSClientVisibleFailure(c, "error", clientError) + } + return nil, fmt.Errorf("upstream http bridge request failed: %s", safeErr) + } + if resp.StatusCode < 400 { + break } - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - _ = writeClientMessage(buildOpenAIWSHTTPBridgeErrorEvent(http.StatusBadGateway, "Upstream request failed")) - return nil, fmt.Errorf("upstream http bridge request failed: %s", safeErr) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) + _ = resp.Body.Close() + retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody) + if retryErr != nil { + return nil, fmt.Errorf("normalize websocket http bridge rejected field retry: %w", retryErr) + } + if changed && rejectedFieldRetryState.Allow(retryBody) { + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_rejected_field_retry account_id=%d turn=%d reason=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(retryReason, openAIWSLogValueMaxLen), + ) + body = retryBody + payloadBytes = len(body) + continue + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) if upstreamMsg == "" { upstreamMsg = http.StatusText(resp.StatusCode) @@ -344,12 +417,17 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, respBody) } if account.Platform != PlatformGrok && (shouldFailover || shouldCooldownOpenAITransientUpstreamError(resp.StatusCode, respBody)) { - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, canonicalModel) + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, actualModel) + } + clientError := buildOpenAIWSHTTPBridgeErrorEvent(resp.StatusCode, upstreamMsg) + if writeErr := writeClientMessage(clientError); writeErr == nil { + markOpenAIWSClientVisibleFailure(c, "error", clientError) } - _ = writeClientMessage(buildOpenAIWSHTTPBridgeErrorEvent(resp.StatusCode, upstreamMsg)) return nil, fmt.Errorf("upstream http bridge error: status=%d message=%s", resp.StatusCode, upstreamMsg) } + defer func() { _ = resp.Body.Close() }() + stopCancelBody := context.AfterFunc(ctx, func() { _ = resp.Body.Close() }) + defer stopCancelBody() if account.Platform == PlatformGrok { s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.Header, resp.StatusCode) } @@ -372,14 +450,14 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( pendingClientMessageBytes := int64(0) capacityFailoverSuppressedLogged := false clientDisconnected := false - mappedModel := "" + bareErrorPending := false + var bareErrorPayload []byte + bareErrorMessage := "" + failureAccountSideEffectsApplied := false + mappedModel := actualModel needModelReplace := false var mappedModelBytes []byte if originalModel != "" { - mappedModel = strings.TrimSpace(gjson.GetBytes(body, "model").String()) - if mappedModel == "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) - } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -431,8 +509,48 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( scanner.Buffer(scanBuf[:0], maxLineSize) defer putSSEScannerBuf64K(scanBuf) + pendingSSEEventType := "" + finalizeBareError := func() error { + if !bareErrorPending { + return nil + } + if !failureAccountSideEffectsApplied { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, mappedModel, resp.Header, bareErrorPayload) + } + upstreamTerminalEvent = "response.failed" + if clientDisconnected { + return nil + } + clientMessage := buildOpenAIWSHTTPBridgeFailedEvent(responseID, originalModel, bareErrorPayload, bareErrorMessage) + if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(clientMessage); changed { + clientMessage = rewritten + } + messages := append(pendingClientMessages, clientMessage) + pendingClientMessages = nil + pendingClientMessageBytes = 0 + for _, message := range messages { + if err := writeClientMessage(message); err != nil { + if isOpenAIWSClientDisconnectError(err) { + clientDisconnected = true + return nil + } + return fmt.Errorf("write synthesized websocket response.failed: %w", err) + } + wroteDownstream = true + } + markOpenAIWSClientVisibleFailure(c, "response.failed", clientMessage) + return nil + } for scanner.Scan() { line := scanner.Text() + if eventType, ok := extractOpenAISSEEventLine(line); ok { + pendingSSEEventType = eventType + continue + } + if strings.TrimSpace(line) == "" { + pendingSSEEventType = "" + continue + } data, ok := extractOpenAISSEDataLine(line) if !ok { continue @@ -446,7 +564,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( continue } - upstreamMessage := []byte(trimmedData) + upstreamMessage := []byte(openAICompatPayloadWithEventType(trimmedData, pendingSSEEventType)) if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { upstreamMessage = normalized } @@ -469,7 +587,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( firstTokenMs = &ms } } - if openAIWSEventShouldParseUsage(eventType) { + if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } imageCounter.AddSSEData(upstreamMessage) @@ -485,6 +603,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( replayCollector.AddEvent(eventType, upstreamMessage) var upstreamEventErr error + suppressClientMessage := bareErrorPending && eventType != "response.failed" if eventType == "error" || eventType == "response.failed" { errMessage := extractOpenAISSEErrorMessage(upstreamMessage) if errMessage == "" { @@ -494,8 +613,10 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( shouldFailover := openAIStreamFailedEventShouldFailover(upstreamMessage, errMessage) if eventType == "error" { errCodeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(upstreamMessage) - statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) - shouldFailover = s.shouldFailoverOpenAIUpstreamResponse(statusCode, errMessage, upstreamMessage) + shouldFailover = openAIStreamErrorEventShouldFailover(upstreamMessage, errMessage) + if account.Platform == PlatformGrok { + statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) + } } requestScopedCapacity := isOpenAIUpstreamCapacityShedEvent(upstreamMessage) if account.Platform == PlatformGrok && eventType == "error" { @@ -509,13 +630,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( shouldFailover = s.shouldFailoverGrokUpstreamError(statusCode, upstreamMessage) s.handleGrokAccountUpstreamError(ctx, account, statusCode, resp.Header, upstreamMessage) } - } else if eventType == "error" && shouldFailover && !requestScopedCapacity { - accountStatus := statusCode - if transientStatus := openAIWSPayloadTransientStatus(upstreamMessage); transientStatus != 0 { - accountStatus = transientStatus - } - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - s.handleOpenAIAccountUpstreamError(ctx, account, accountStatus, resp.Header, upstreamMessage, canonicalModel) } if !wroteDownstream && shouldFailover && (turn == 1 || statusCode == http.StatusTooManyRequests) { if account.Platform == PlatformGrok { @@ -523,12 +637,24 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } return nil, s.newOpenAIStreamFailoverError(c, account, true, resp.Header.Get("x-request-id"), upstreamMessage, errMessage, resp.Header) } + if account.Platform != PlatformGrok && !failureAccountSideEffectsApplied { + if eventType == "response.failed" || (shouldFailover && !requestScopedCapacity) { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, mappedModel, resp.Header, upstreamMessage) + } + } if wroteDownstream && requestScopedCapacity && !capacityFailoverSuppressedLogged { logOpenAICapacityFailoverSuppressed(ctx, account, "ws_http_bridge", resp.Header.Get("x-request-id"), eventType) capacityFailoverSuppressedLogged = true } - if eventType == "error" { + if eventType == "error" && account.Platform == PlatformGrok { upstreamEventErr = errors.New(errMessage) + } else if eventType == "error" { + bareErrorPending = true + bareErrorPayload = append(bareErrorPayload[:0], upstreamMessage...) + bareErrorMessage = errMessage + suppressClientMessage = true + } else { + bareErrorPending = false } } @@ -542,7 +668,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( clientMessage = rewritten } } - if !clientDisconnected { + if !clientDisconnected && !suppressClientMessage { stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream commitStagedMessages := !stageBeforeSemanticOutput || openAIStreamDataStartsClientOutput(string(clientMessage), eventType) || @@ -589,12 +715,19 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } } } + if !clientDisconnected && !suppressClientMessage { + markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage) + } if upstreamEventErr != nil { return resultWithUsage(), upstreamEventErr } - if isOpenAIWSTerminalEvent(eventType) { - upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage) + if isOpenAIWSTerminalEvent(eventType) && !bareErrorPending { + if eventType == "response.failed" { + upstreamTerminalEvent = "response.failed" + } else { + upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, resp.Header, upstreamMessage) + } terminalEventCount++ firstTokenMsValue := -1 if firstTokenMs != nil { @@ -618,6 +751,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return resultWithUsage(), nil } } + if bareErrorPending { + if finalizeErr := finalizeBareError(); finalizeErr != nil { + return resultWithUsage(), finalizeErr + } + if scanErr := scanner.Err(); scanErr != nil { + return resultWithUsage(), fmt.Errorf("read upstream http bridge stream after error event: %w", scanErr) + } + return resultWithUsage(), errors.New(bareErrorMessage) + } if err := scanner.Err(); err != nil { streamErr := fmt.Errorf("read upstream http bridge stream: %w", err) if turn == 1 && !wroteDownstream { diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go index b6719d493b..074c08f8ce 100644 --- a/backend/internal/service/openai_ws_http_bridge_resume_test.go +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -97,7 +97,10 @@ func TestProxyOpenAIWSHTTPBridgeTurnLaterTurnDoesNotFailOverAfterDownstreamOutpu require.False(t, errors.As(err, &failoverErr)) require.Len(t, writes, 2) require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String()) - require.Equal(t, "error", gjson.GetBytes(writes[1], "type").String()) + // P0.1: after downstream output, retain the upstream error and synthesize + // the official Responses terminal so Codex clients do not see a bare error + // followed by a closed stream. + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) } func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t *testing.T) { diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 9617bd464d..15780d399c 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) { } func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { - body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi"}`)) + body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) require.NoError(t, err) require.False(t, gjson.GetBytes(body, "type").Exists()) require.False(t, gjson.GetBytes(body, "generate").Exists()) @@ -39,6 +39,9 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.Equal(t, "gpt-5", gjson.GetBytes(body, "model").String()) require.True(t, gjson.GetBytes(body, "stream").Bool()) require.Equal(t, "hi", gjson.GetBytes(body, "input").String()) + require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw) + _, err = prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create"}{"trailing":true}`)) + require.Error(t, err) } func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { @@ -422,6 +425,52 @@ func TestProxyOpenAIWSHTTPBridgeTurnHTTPStatusFailoverSafety(t *testing.T) { } } +func TestProxyOpenAIWSHTTPBridgeTurnRetriesRejectedFieldBeforeClientOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamSequenceRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":{"type":"invalid_request_error","code":"invalid_parameter","param":"truncation","message":"Unsupported parameter: truncation"}}`, + )), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_retry\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + )), + }, + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 91, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi","truncation":"auto"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 2, upstream.callCount) + require.Len(t, upstream.bodies, 2) + require.True(t, gjson.GetBytes(upstream.bodies[0], "truncation").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists()) + require.Len(t, writes, 1) + require.Equal(t, "response.completed", gjson.GetBytes(writes[0], "type").String()) +} + func TestProxyOpenAIWSHTTPBridgeTurnSSEErrorFailoverSafety(t *testing.T) { gin.SetMode(gin.TestMode) @@ -522,6 +571,68 @@ func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing } } +func TestProxyOpenAIWSHTTPBridgeTurnBareErrorUsesAuthoritativeFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.Join([]string{ + `data: {"type":"response.output_text.delta","response_id":"resp_failed","delta":"partial"}`, + ``, + `data: {"type":"error","error":{"status_code":403,"code":"workspace_suspended","message":"workspace is suspended"}}`, + ``, + `data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"status_code":403,"code":"workspace_suspended","message":"workspace is suspended"}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}} + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 2, result.Usage.OutputTokens) + require.Equal(t, 1, repo.setErrorCalls) + require.Len(t, writes, 2) + require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String()) + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnBareErrorEOFSynthesizesFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_eof\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }) + + require.EqualError(t, err, "bad request") + require.NotNil(t, result) + require.Equal(t, "response.failed", result.UpstreamTerminalEvent) + require.Len(t, writes, 2) + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) + require.Equal(t, "failed", gjson.GetBytes(writes[1], "response.status").String()) + require.Equal(t, "resp_eof", gjson.GetBytes(writes[1], "response.id").String()) +} + func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *testing.T) { gin.SetMode(gin.TestMode) body := strings.Join([]string{ diff --git a/backend/internal/service/openai_ws_ops_failure_test.go b/backend/internal/service/openai_ws_ops_failure_test.go new file mode 100644 index 0000000000..02c122c46d --- /dev/null +++ b/backend/internal/service/openai_ws_ops_failure_test.go @@ -0,0 +1,57 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestMarkOpenAIWSClientVisibleFailure_ResponseFailedNestedError(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "response.failed", []byte(`{"type":"response.failed","response":{"error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"too long","status_code":400}}}`)) + + got, ok := GetOpsStreamError(c) + require.True(t, ok) + require.True(t, got.CountTowardsSLA) + require.Equal(t, http.StatusBadRequest, got.IntendedStatus) + require.Equal(t, "invalid_request_error", got.ErrType) + require.Equal(t, "context_length_exceeded", got.Code) + require.Equal(t, "too long", got.Message) +} + +func TestMarkOpenAIWSClientVisibleFailure_ErrorAndSuccessBoundary(t *testing.T) { + t.Run("error", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "error", []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)) + got, ok := GetOpsStreamError(c) + require.True(t, ok) + require.Equal(t, http.StatusTooManyRequests, got.IntendedStatus) + }) + + t.Run("completed does not mark", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "response.completed", []byte(`{"type":"response.completed"}`)) + _, ok := GetOpsStreamError(c) + require.False(t, ok) + }) +} + +func TestSetOpsUpstreamModelStoresOnlyTrimmedModelSlug(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + SetOpsUpstreamModel(c, " gpt-5.6-sol ") + value, ok := c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", value) + + SetOpsUpstreamModel(c, " ") + value, ok = c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", value) + + ClearOpsUpstreamModel(c) + value, ok = c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "", value) +} diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go index 32779a23f3..6ee33103d7 100644 --- a/backend/internal/service/openai_ws_protocol_forward_test.go +++ b/backend/internal/service/openai_ws_protocol_forward_test.go @@ -178,7 +178,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testi require.NotNil(t, result) require.False(t, result.OpenAIWSMode, "HTTP 入站应保持 HTTP 转发") require.NotNil(t, upstream.lastReq, "HTTP 入站应命中 HTTP 上游") - require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists(), "HTTP 路径应沿用原逻辑移除 previous_response_id") + require.Equal(t, "resp_http_keep", gjson.GetBytes(upstream.lastBody, "previous_response_id").String(), "API-key HTTP must preserve official Responses continuation") decision, _ := c.Get("openai_ws_transport_decision") reason, _ := c.Get("openai_ws_transport_reason") @@ -257,11 +257,11 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentO firstBody := upstream.bodies[0] secondBody := upstream.bodies[1] - require.False(t, gjson.GetBytes(firstBody, "previous_response_id").Exists(), "HTTP 首次请求仍应沿用原逻辑移除 previous_response_id") + require.Equal(t, "resp_http_retry", gjson.GetBytes(firstBody, "previous_response_id").String(), "API-key HTTP must preserve continuation on the first attempt") require.True(t, gjson.GetBytes(firstBody, "input.0.encrypted_content").Exists(), "首次请求不应做发送前预清理") require.Equal(t, "keep me", gjson.GetBytes(firstBody, "input.0.summary.0.text").String()) - require.False(t, gjson.GetBytes(secondBody, "previous_response_id").Exists(), "HTTP 精确重试不应重新带回 previous_response_id") + require.Equal(t, "resp_http_retry", gjson.GetBytes(secondBody, "previous_response_id").String(), "encrypted-content retry must preserve API-key continuation") require.False(t, gjson.GetBytes(secondBody, "input.0.encrypted_content").Exists(), "精确重试应移除 reasoning.encrypted_content") require.Equal(t, "keep me", gjson.GetBytes(secondBody, "input.0.summary.0.text").String(), "精确重试应保留有效 reasoning summary") require.Equal(t, "input_text", gjson.GetBytes(secondBody, "input.1.type").String(), "非 reasoning input 应保持原样") @@ -356,7 +356,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedC require.Equal(t, "client_protocol_http", reason) } -func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t *testing.T) { +func TestOpenAIGatewayService_Forward_APIKeyHTTPPreservesPreviousResponseIDWhenWSDisabled(t *testing.T) { gin.SetMode(gin.TestMode) wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) @@ -409,7 +409,7 @@ func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(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.Equal(t, "resp_123", gjson.GetBytes(upstream.lastBody, "previous_response_id").String()) } func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) { diff --git a/backend/internal/service/openai_ws_protocol_resolver.go b/backend/internal/service/openai_ws_protocol_resolver.go index e11975bf37..a34dc543d9 100644 --- a/backend/internal/service/openai_ws_protocol_resolver.go +++ b/backend/internal/service/openai_ws_protocol_resolver.go @@ -56,7 +56,7 @@ func (r *defaultOpenAIWSProtocolResolver) Resolve(account *Account) OpenAIWSProt if !wsCfg.Enabled { return openAIWSHTTPDecision("global_disabled") } - if account.IsOpenAIOAuth() { + if account.IsOpenAIOAuthLike() { if !wsCfg.OAuthEnabled { return openAIWSHTTPDecision("oauth_disabled") } diff --git a/backend/internal/service/openai_ws_protocol_resolver_test.go b/backend/internal/service/openai_ws_protocol_resolver_test.go index 6d18604d03..12e047f100 100644 --- a/backend/internal/service/openai_ws_protocol_resolver_test.go +++ b/backend/internal/service/openai_ws_protocol_resolver_test.go @@ -29,6 +29,32 @@ func TestOpenAIWSProtocolResolver_Resolve(t *testing.T) { require.Equal(t, "ws_v2_enabled", decision.Reason) }) + t.Run("OpenAI setup token复用OAuth开关", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } + decision := NewOpenAIWSProtocolResolver(baseCfg).Resolve(account) + require.Equal(t, OpenAIUpstreamTransportResponsesWebsocketV2, decision.Transport) + require.Equal(t, "ws_v2_enabled", decision.Reason) + }) + + t.Run("非OpenAI setup token不进入OAuth WS", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeSetupToken, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } + decision := NewOpenAIWSProtocolResolver(baseCfg).Resolve(account) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "platform_not_openai", decision.Reason) + }) + t.Run("v2关闭时回退v1", func(t *testing.T) { cfg := *baseCfg cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = false diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go index 9d1fdaf765..13346cc460 100644 --- a/backend/internal/service/openai_ws_ratelimit_signal_test.go +++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go @@ -552,3 +552,30 @@ func TestOpenAIWSErrorHTTPStatusFromRaw_UsageLimitReachedIs429(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, openAIWSErrorHTTPStatusFromRaw("", "usage_limit_reached")) require.Equal(t, http.StatusTooManyRequests, openAIWSErrorHTTPStatusFromRaw("rate_limit_exceeded", "")) } + +func TestOpenAIWSRateLimitFailoverError_OAuthKeepsSameAccountDeadline(t *testing.T) { + svc := &OpenAIGatewayService{} + headers := http.Header{"Retry-After": []string{"30"}} + body := []byte(`{"error":{"type":"rate_limit_error","message":"limited"}}`) + + oauthErr := svc.newOpenAIWSRateLimitFailoverError(&Account{ + ID: 904, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }, headers, body, "limited") + require.True(t, oauthErr.RetryableOnSameAccount) + require.False(t, oauthErr.SameAccountRetryDeadline.IsZero()) + require.Positive(t, oauthErr.SameAccountRetryDelay) + require.LessOrEqual(t, oauthErr.SameAccountRetryDelay, openAIOAuth429MaxRetryDelay) + require.Equal(t, body, oauthErr.ResponseBody) + require.Equal(t, "30", oauthErr.ResponseHeaders.Get("Retry-After")) + + apiKeyErr := svc.newOpenAIWSRateLimitFailoverError(&Account{ + ID: 905, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + }, headers, body, "limited") + require.False(t, apiKeyErr.RetryableOnSameAccount) + require.True(t, apiKeyErr.SameAccountRetryDeadline.IsZero()) + require.Zero(t, apiKeyErr.SameAccountRetryDelay) +} diff --git a/backend/internal/service/openai_ws_session_preemption.go b/backend/internal/service/openai_ws_session_preemption.go new file mode 100644 index 0000000000..1f69426da4 --- /dev/null +++ b/backend/internal/service/openai_ws_session_preemption.go @@ -0,0 +1,231 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +var errOpenAIWSSessionPreempted = errors.New("openai ws session preempted by newer request") + +const ( + openAIWSSessionPreemptOwnerTTL = 2 * time.Hour + openAIWSSessionPreemptWatchInterval = 2 * time.Second + openAIWSSessionPreemptCachePrefix = "wspreempt:" +) + +// OpenAIWSSessionPreemptionCache is an optional GatewayCache capability. The +// production Redis cache implements all operations atomically; cache stubs do +// not need to implement it for ordinary gateway tests. +type OpenAIWSSessionPreemptionCache interface { + ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) + CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) + CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) +} + +func NewOpenAIWSSessionPreemptedError() error { + return errOpenAIWSSessionPreempted +} + +type openAIWSSessionPreemptKey struct { + groupID int64 + apiKeyID int64 + sessionHash string +} + +func newOpenAIWSSessionPreemptKey(groupID, apiKeyID int64, sessionHash string) (openAIWSSessionPreemptKey, bool) { + sessionHash = strings.TrimSpace(sessionHash) + if groupID <= 0 || apiKeyID <= 0 || sessionHash == "" { + return openAIWSSessionPreemptKey{}, false + } + return openAIWSSessionPreemptKey{groupID: groupID, apiKeyID: apiKeyID, sessionHash: sessionHash}, true +} + +func openAIWSSessionPreemptCacheHash(apiKeyID int64, sessionHash string) string { + return fmt.Sprintf("%s%d:%s", openAIWSSessionPreemptCachePrefix, apiKeyID, strings.TrimSpace(sessionHash)) +} + +type openAIWSSessionPreemptEntry struct { + generation uint64 + cancel func() +} + +type openAIWSSessionPreemptRegistry struct { + mu sync.Mutex + next uint64 + active map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry +} + +func (r *openAIWSSessionPreemptRegistry) Begin(key openAIWSSessionPreemptKey, cancel func()) (cleanup func(), preemptedPrevious bool) { + if r == nil || strings.TrimSpace(key.sessionHash) == "" { + return func() {}, false + } + r.mu.Lock() + if r.active == nil { + r.active = make(map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry) + } + r.next++ + generation := r.next + previous, hadPrevious := r.active[key] + r.active[key] = openAIWSSessionPreemptEntry{generation: generation, cancel: cancel} + r.mu.Unlock() + if hadPrevious && previous.cancel != nil { + previous.cancel() + } + return func() { + r.mu.Lock() + defer r.mu.Unlock() + current, ok := r.active[key] + if ok && current.generation == generation { + delete(r.active, key) + } + }, hadPrevious +} + +func (s *OpenAIGatewayService) beginOpenAIWSSessionPreemptContext( + ctx context.Context, + account *Account, + groupID, apiKeyID int64, + sessionHash string, + httpIngressWSOneShot bool, +) (context.Context, func(), bool, bool) { + if ctx == nil { + ctx = context.Background() + } + if s == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth || httpIngressWSOneShot { + return ctx, func() {}, false, false + } + key, ok := newOpenAIWSSessionPreemptKey(groupID, apiKeyID, sessionHash) + if !ok { + return ctx, func() {}, false, false + } + + preemptCtx, cancel := context.WithCancelCause(ctx) + ownerToken := uuid.NewString() + var preemptOnce sync.Once + preempt := func() { + preemptOnce.Do(func() { + if stateStore := s.getOpenAIWSStateStore(); stateStore != nil { + stateStore.DeleteSessionTurnState(key.groupID, key.sessionHash) + stateStore.DeleteSessionConn(key.groupID, key.sessionHash) + } + cancel(errOpenAIWSSessionPreempted) + }) + } + previousRemoteOwner, remoteClaimed := s.claimOpenAIWSSessionPreemptOwner(ctx, key, ownerToken) + preemptedPrevious := remoteClaimed && previousRemoteOwner != "" && previousRemoteOwner != ownerToken + cleanupLocal, hadLocalPrevious := s.openaiWSSessionPreemptions.Begin(key, preempt) + preemptedPrevious = preemptedPrevious || hadLocalPrevious + stopWatch := func() {} + if remoteClaimed { + stopWatch = s.watchOpenAIWSSessionPreemptOwner(preemptCtx, key, ownerToken, preempt) + } + + return preemptCtx, func() { + stopWatch() + cleanupLocal() + if remoteClaimed { + s.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, ownerToken) + } + cancel(nil) + }, true, preemptedPrevious +} + +func (s *OpenAIGatewayService) openAIWSSessionPreemptionCache() OpenAIWSSessionPreemptionCache { + if s == nil || s.cache == nil { + return nil + } + cache, _ := s.cache.(OpenAIWSSessionPreemptionCache) + return cache +} + +func (s *OpenAIGatewayService) claimOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) (string, bool) { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || strings.TrimSpace(ownerToken) == "" { + return "", false + } + cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout) + defer cancel() + previous, err := cache.ClaimOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + openAIWSSessionPreemptOwnerTTL, + ) + if err != nil { + return "", false + } + return strings.TrimSpace(string(previous)), true +} + +func (s *OpenAIGatewayService) releaseOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || strings.TrimSpace(ownerToken) == "" { + return + } + cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout) + defer cancel() + _, _ = cache.CompareAndDeleteOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + ) +} + +func (s *OpenAIGatewayService) watchOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string, onLost func()) func() { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || onLost == nil || strings.TrimSpace(ownerToken) == "" { + return func() {} + } + stopCh := make(chan struct{}) + var once sync.Once + go func() { + ticker := time.NewTicker(openAIWSSessionPreemptWatchInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ctx.Done(): + return + case <-ticker.C: + cacheCtx, cancel := context.WithTimeout(context.Background(), openAIWSStateStoreRedisTimeout) + owned, err := cache.CompareAndRefreshOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + openAIWSSessionPreemptOwnerTTL, + ) + cancel() + if err == nil && !owned { + onLost() + return + } + } + } + }() + return func() { once.Do(func() { close(stopCh) }) } +} + +func isOpenAIWSSessionPreempted(ctx context.Context) bool { + return ctx != nil && errors.Is(context.Cause(ctx), errOpenAIWSSessionPreempted) +} + +func IsOpenAIWSSessionPreemptedError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, errOpenAIWSSessionPreempted) { + return true + } + var fallbackErr *openAIWSFallbackError + return errors.As(err, &fallbackErr) && fallbackErr != nil && strings.TrimPrefix(strings.TrimSpace(fallbackErr.Reason), "prewarm_") == "session_preempted" +} diff --git a/backend/internal/service/openai_ws_session_preemption_test.go b/backend/internal/service/openai_ws_session_preemption_test.go new file mode 100644 index 0000000000..42b5b308bf --- /dev/null +++ b/backend/internal/service/openai_ws_session_preemption_test.go @@ -0,0 +1,142 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIWSSessionPreemptRegistryCancelsSameScopedSessionOnly(t *testing.T) { + var registry openAIWSSessionPreemptRegistry + key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"} + other := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 12, sessionHash: "sess"} + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstCleanup, replaced := registry.Begin(key, firstCancel) + require.False(t, replaced) + otherCtx, otherCancel := context.WithCancel(context.Background()) + otherCleanup, replaced := registry.Begin(other, otherCancel) + require.False(t, replaced) + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondCleanup, replaced := registry.Begin(key, secondCancel) + require.True(t, replaced) + + require.ErrorIs(t, firstCtx.Err(), context.Canceled) + require.NoError(t, otherCtx.Err()) + require.NoError(t, secondCtx.Err()) + firstCleanup() + require.NoError(t, secondCtx.Err(), "stale cleanup must not remove the replacement") + + secondCleanup() + otherCleanup() +} + +type openAIWSSessionPreemptCacheStub struct { + GatewayCache + mu sync.Mutex + owners map[string][]byte +} + +func (c *openAIWSSessionPreemptCacheStub) key(groupID int64, hash string) string { + return fmt.Sprintf("%d:%s", groupID, hash) +} + +func (c *openAIWSSessionPreemptCacheStub) ClaimOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, owner []byte, _ time.Duration) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.owners == nil { + c.owners = make(map[string][]byte) + } + key := c.key(groupID, hash) + previous := append([]byte(nil), c.owners[key]...) + c.owners[key] = append([]byte(nil), owner...) + return previous, nil +} + +func (c *openAIWSSessionPreemptCacheStub) CompareAndRefreshOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte, _ time.Duration) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + return string(c.owners[c.key(groupID, hash)]) == string(expected), nil +} + +func (c *openAIWSSessionPreemptCacheStub) CompareAndDeleteOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + key := c.key(groupID, hash) + if string(c.owners[key]) != string(expected) { + return false, nil + } + delete(c.owners, key) + return true, nil +} + +func TestOpenAIWSSessionPreemptContextEligibilityAndLocalCancellation(t *testing.T) { + stateStore := NewOpenAIWSStateStore(nil) + svc := &OpenAIGatewayService{openaiWSStateStore: stateStore} + oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKey := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + grok := &Account{ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth} + + _, cleanup, armed, _ := svc.beginOpenAIWSSessionPreemptContext(context.Background(), apiKey, 7, 11, "sess", false) + cleanup() + require.False(t, armed) + _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), grok, 7, 11, "sess", false) + cleanup() + require.False(t, armed) + _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", true) + cleanup() + require.False(t, armed, "HTTP-ingress one-shot must not participate") + + firstCtx, firstCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false) + require.True(t, armed) + require.False(t, replaced) + stateStore.BindSessionTurnState(7, "sess", "turn-state", time.Hour) + stateStore.BindSessionConn(7, "sess", "conn-1", time.Hour) + _, secondCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false) + require.True(t, armed) + require.True(t, replaced) + require.True(t, isOpenAIWSSessionPreempted(firstCtx)) + require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx))) + _, turnStateExists := stateStore.GetSessionTurnState(7, "sess") + _, sessionConnExists := stateStore.GetSessionConn(7, "sess") + require.False(t, turnStateExists) + require.False(t, sessionConnExists) + firstCleanup() + secondCleanup() +} + +func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) { + cache := &openAIWSSessionPreemptCacheStub{} + svc := &OpenAIGatewayService{cache: cache} + key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"} + + previous, ok := svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a") + require.True(t, ok) + require.Empty(t, previous) + previous, ok = svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-b") + require.True(t, ok) + require.Equal(t, "owner-a", previous) + svc.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a") + + cache.mu.Lock() + current := string(cache.owners[cache.key(key.groupID, openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash))]) + cache.mu.Unlock() + require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner") +} + +func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) { + _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess") + require.False(t, ok) + _, ok = newOpenAIWSSessionPreemptKey(7, 0, "sess") + require.False(t, ok) + _, ok = newOpenAIWSSessionPreemptKey(7, 11, " ") + require.False(t, ok) + key, ok := newOpenAIWSSessionPreemptKey(7, 11, " sess ") + require.True(t, ok) + require.Equal(t, "sess", key.sessionHash) +} diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index abc94e8f1c..992966bee5 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -1,6 +1,7 @@ package openai_ws_v2 import ( + "bytes" "context" "errors" "io" @@ -94,6 +95,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage + turnUsage Usage requestModelMu sync.RWMutex requestModel string pendingTurnStart atomic.Pointer[time.Time] @@ -104,6 +106,7 @@ type relayState struct { firstTokenMs *int turnTimingByID map[string]*relayTurnTiming activeTurn *relayTurnTiming + pendingBareError *observedUpstreamEvent } type relayExitSignal struct { @@ -343,6 +346,7 @@ func Relay( relayCancel() _ = upstreamConn.Close() + emitTurnComplete(options.OnTurnComplete, state, finalizePendingBareError(state, nowFn())) enrichResult(&result, state, nowFn().Sub(startAt)) result.ClientToUpstreamFrames = clientToUpstreamFrames.Load() result.UpstreamToClientFrames = upstreamToClientFrames.Load() @@ -509,6 +513,7 @@ func runUpstreamToClient( for { msgType, payload, err := upstreamConn.ReadFrame(ctx) if err != nil { + emitTurnComplete(onTurnComplete, state, finalizePendingBareError(state, nowFn())) emitRelayTrace(onTrace, RelayTraceEvent{ Stage: "read_upstream_failed", Direction: "upstream_to_client", @@ -546,6 +551,10 @@ func runUpstreamToClient( observedEvent := observedUpstreamEvent{} switch msgType { case coderws.MessageText: + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if shouldFinalizePendingBareError(state, payload, eventType) { + emitTurnComplete(onTurnComplete, state, finalizePendingBareError(state, nowFn())) + } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 @@ -738,8 +747,59 @@ func observeUpstreamMessage( if !isTerminalEvent(eventType) { return observed } - observed.terminal = true state.terminalEventType = eventType + if eventType == "error" { + // Some Responses servers emit error immediately before response.failed. + // Defer turn settlement so the authoritative failed usage can replace + // this fallback instead of billing both terminal frames. + if observed.responseID == "" { + observed.responseID = openAIWSRelayActiveTurnID(state) + } + pending := observed + state.pendingBareError = &pending + return observed + } + state.pendingBareError = nil + return finalizeObservedRelayTerminal(state, observed, now) +} + +func shouldFinalizePendingBareError(state *relayState, payload []byte, eventType string) bool { + if state == nil || state.pendingBareError == nil { + return false + } + eventType = strings.TrimSpace(eventType) + if eventType == "" || eventType == "error" || eventType == "response.failed" { + return false + } + if isTerminalEvent(eventType) || eventType == "response.created" { + return true + } + // Auxiliary provider frames may be interleaved between error and its + // authoritative response.failed. Only a response event identifying a + // different turn closes the pending error. + responseID := strings.TrimSpace(gjson.GetBytes(payload, "response.id").String()) + if responseID == "" || state.pendingBareError.responseID == "" { + return false + } + return responseID != state.pendingBareError.responseID +} + +func finalizePendingBareError(state *relayState, now time.Time) observedUpstreamEvent { + if state == nil || state.pendingBareError == nil { + return observedUpstreamEvent{} + } + observed := *state.pendingBareError + state.pendingBareError = nil + return finalizeObservedRelayTerminal(state, observed, now) +} + +func finalizeObservedRelayTerminal(state *relayState, observed observedUpstreamEvent, now time.Time) observedUpstreamEvent { + if state == nil || strings.TrimSpace(observed.eventType) == "" { + return observedUpstreamEvent{} + } + observed.usage = finalizeRelayTurnUsage(state) + observed.terminal = true + responseID := strings.TrimSpace(observed.responseID) if responseID != "" { state.lastResponseID = responseID if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok { @@ -755,6 +815,9 @@ func observeUpstreamMessage( observed.duration = duration observed.firstToken = openAIWSRelayCloneIntPtr(turnTiming.firstTokenMs) } + } else { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) } return observed } @@ -768,7 +831,7 @@ func emitTurnComplete( return } responseID := strings.TrimSpace(observed.responseID) - if responseID == "" { + if responseID == "" && strings.TrimSpace(observed.eventType) != "error" { return } requestModel := "" @@ -890,6 +953,31 @@ func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayT return *timing, true } +func openAIWSRelayDiscardActiveTurnTiming(state *relayState) { + if state == nil || state.activeTurn == nil { + return + } + active := state.activeTurn + for responseID, timing := range state.turnTimingByID { + if timing == active { + delete(state.turnTimingByID, responseID) + } + } + state.activeTurn = nil +} + +func openAIWSRelayActiveTurnID(state *relayState) string { + if state == nil || state.activeTurn == nil { + return "" + } + for responseID, timing := range state.turnTimingByID { + if timing == state.activeTurn { + return responseID + } + } + return "" +} + func openAIWSRelayCloneIntPtr(v *int) *int { if v == nil { return nil @@ -904,10 +992,13 @@ func parseUsageAndAccumulate( eventType string, onParseFailure func(eventType string, usageRaw string), ) Usage { - if state == nil || len(message) == 0 || !shouldParseUsage(eventType) { + if state == nil || len(message) == 0 || !shouldParseUsage(eventType) || !bytes.Contains(message, []byte(`"usage"`)) { return Usage{} } usageResult := gjson.GetBytes(message, "response.usage") + if !usageResult.Exists() { + usageResult = gjson.GetBytes(message, "usage") + } if !usageResult.Exists() { return Usage{} } @@ -920,25 +1011,26 @@ func parseUsageAndAccumulate( return Usage{} } - inputResult := gjson.GetBytes(message, "response.usage.input_tokens") + inputResult := usageResult.Get("input_tokens") if !inputResult.Exists() { - inputResult = gjson.GetBytes(message, "response.usage.prompt_tokens") + inputResult = usageResult.Get("prompt_tokens") } - outputResult := gjson.GetBytes(message, "response.usage.output_tokens") + outputResult := usageResult.Get("output_tokens") if !outputResult.Exists() { - outputResult = gjson.GetBytes(message, "response.usage.completion_tokens") + outputResult = usageResult.Get("completion_tokens") } - cachedResult := gjson.GetBytes(message, "response.usage.input_tokens_details.cached_tokens") + cachedResult := usageResult.Get("input_tokens_details.cached_tokens") if !cachedResult.Exists() { - cachedResult = gjson.GetBytes(message, "response.usage.prompt_tokens_details.cached_tokens") + cachedResult = usageResult.Get("prompt_tokens_details.cached_tokens") } imageTokens := usageResult.Get("output_tokens_details.image_tokens").Int() if imageTokens == 0 { imageTokens = usageResult.Get("completion_tokens_details.image_tokens").Int() } - inputTokens, inputOK := parseUsageIntField(inputResult, true) - outputTokens, outputOK := parseUsageIntField(outputResult, true) + requireTotals := isTerminalEvent(strings.TrimSpace(eventType)) + inputTokens, inputOK := parseUsageIntField(inputResult, requireTotals) + outputTokens, outputOK := parseUsageIntField(outputResult, requireTotals) cachedTokens, cachedOK := parseUsageIntField(cachedResult, false) if !inputOK || !outputOK || !cachedOK { recordUsageParseFailure() @@ -956,14 +1048,58 @@ func parseUsageAndAccumulate( ImageOutputTokens: int(imageTokens), } - state.usage.InputTokens += parsedUsage.InputTokens - state.usage.OutputTokens += parsedUsage.OutputTokens - state.usage.CacheCreationInputTokens += parsedUsage.CacheCreationInputTokens - state.usage.CacheReadInputTokens += parsedUsage.CacheReadInputTokens - state.usage.ImageOutputTokens += parsedUsage.ImageOutputTokens + if isTerminalEvent(strings.TrimSpace(eventType)) { + if relayUsageHasTokens(parsedUsage) || !relayUsageHasTokens(state.turnUsage) { + state.turnUsage = parsedUsage + } + } else { + mergeRelayUsageNonZero(&state.turnUsage, parsedUsage) + return Usage{} + } return parsedUsage } +func relayUsageHasTokens(usage Usage) bool { + return usage.InputTokens > 0 || usage.OutputTokens > 0 || + usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0 || + usage.ImageOutputTokens > 0 +} + +func mergeRelayUsageNonZero(dst *Usage, src Usage) { + if dst == nil { + return + } + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.OutputTokens > 0 { + dst.OutputTokens = src.OutputTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } + if src.ImageOutputTokens > 0 { + dst.ImageOutputTokens = src.ImageOutputTokens + } +} + +func finalizeRelayTurnUsage(state *relayState) Usage { + if state == nil { + return Usage{} + } + turnUsage := state.turnUsage + state.usage.InputTokens += turnUsage.InputTokens + state.usage.OutputTokens += turnUsage.OutputTokens + state.usage.CacheCreationInputTokens += turnUsage.CacheCreationInputTokens + state.usage.CacheReadInputTokens += turnUsage.CacheReadInputTokens + state.usage.ImageOutputTokens += turnUsage.ImageOutputTokens + state.turnUsage = Usage{} + return turnUsage +} + func parseUsageIntField(value gjson.Result, required bool) (int, bool) { if !value.Exists() { return 0, !required @@ -1057,8 +1193,8 @@ func isDisconnectError(err error) bool { } func isTerminalEvent(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 @@ -1066,12 +1202,11 @@ func isTerminalEvent(eventType string) bool { } func shouldParseUsage(eventType string) bool { - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + eventType = strings.TrimSpace(eventType) + if eventType == "error" || isTerminalEvent(eventType) { return true - default: - return false } + return strings.HasPrefix(eventType, "response.") && !strings.HasSuffix(eventType, ".delta") } func isTokenEvent(eventType string) bool { diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index ebb2394a12..efbc9881e0 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -307,6 +307,7 @@ func TestParseUsageAndEnrichCoverage(t *testing.T) { require.Equal(t, 0, state.usage.CacheReadInputTokens) parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":2,"output_tokens":1,"input_tokens_details":{"cached_tokens":1,"cache_write_tokens":4},"output_tokens_details":{"image_tokens":3}}}}`), "response.completed", nil) + finalizeRelayTurnUsage(state) require.Equal(t, 2, state.usage.InputTokens) require.Equal(t, 1, state.usage.OutputTokens) require.Equal(t, 1, state.usage.CacheReadInputTokens) @@ -334,6 +335,7 @@ func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { "response.done", nil, ) + finalizeRelayTurnUsage(state) require.Equal(t, 12, got.InputTokens) require.Equal(t, 6, got.OutputTokens) require.Equal(t, 4, got.CacheReadInputTokens) @@ -341,6 +343,139 @@ func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { require.Equal(t, got, state.usage) } +func TestRelayUsageTerminalWithoutUsageKeepsFallback(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"id":"resp_1"}}`), "response.completed", nil) + + turnUsage := finalizeRelayTurnUsage(state) + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, turnUsage) + require.Equal(t, turnUsage, state.usage) +} + +func TestRelayUsageTerminalZeroKeepsFallback(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","usage":{"input_tokens":0,"output_tokens":0}}`), "response.completed", nil) + + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, finalizeRelayTurnUsage(state)) + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, state.usage) +} + +func TestRelayUsageTerminalNonZeroReplacesFallbackAsWhole(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","usage":{"input_tokens":3,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}`), "response.completed", nil) + + require.Equal(t, Usage{InputTokens: 3}, finalizeRelayTurnUsage(state)) + require.Equal(t, Usage{InputTokens: 3}, state.usage) +} + +func TestObserveUpstreamMessageBareErrorClearsTurnStateAndFinalizesUsageOnce(t *testing.T) { + t.Parallel() + + now := time.Unix(200, 0) + state := &relayState{} + state.setPendingTurnStartedAt(now.Add(-2 * time.Second)) + timing := openAIWSRelayGetOrInitTurnTiming(state, "resp_active", now.Add(-time.Second)) + require.NotNil(t, timing) + state.setPendingTurnStartedAt(now.Add(-500 * time.Millisecond)) + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + + observed := observeUpstreamMessage( + state, + []byte(`{"type":"error","usage":{"input_tokens":3,"output_tokens":1},"error":{"message":"failed"}}`), + now.Add(-3*time.Second), + func() time.Time { return now }, + nil, + ) + + require.False(t, observed.terminal, "bare error settlement is deferred in case response.failed follows") + require.Equal(t, "error", observed.eventType) + observed = finalizePendingBareError(state, now) + require.True(t, observed.terminal) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 1}, observed.usage) + require.Equal(t, observed.usage, state.usage) + require.Equal(t, Usage{}, state.turnUsage) + require.Nil(t, state.activeTurn) + require.Empty(t, state.turnTimingByID) + require.True(t, state.consumePendingTurnStartedAt().IsZero()) + + // Re-observing and settling a later terminal without usage must not re-add the prior turn. + observeUpstreamMessage(state, []byte(`{"type":"error","error":{"message":"again"}}`), now, func() time.Time { return now }, nil) + finalizePendingBareError(state, now) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 1}, state.usage) +} + +func TestObserveUpstreamMessageErrorThenFailedSettlesUsageOnce(t *testing.T) { + t.Parallel() + + now := time.Unix(300, 0) + state := &relayState{} + state.setPendingTurnStartedAt(now.Add(-time.Second)) + openAIWSRelayGetOrInitTurnTiming(state, "resp_1", now) + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + + errorObserved := observeUpstreamMessage( + state, + []byte(`{"type":"error","error":{"code":"server_is_overloaded","message":"overloaded"}}`), + now, + func() time.Time { return now }, + nil, + ) + require.False(t, errorObserved.terminal) + require.NotNil(t, state.pendingBareError) + require.Equal(t, Usage{}, state.usage) + + failedObserved := observeUpstreamMessage( + state, + []byte(`{"type":"response.failed","response":{"id":"resp_1","usage":{"input_tokens":7,"output_tokens":2}}}`), + now, + func() time.Time { return now }, + nil, + ) + require.True(t, failedObserved.terminal) + require.Equal(t, Usage{InputTokens: 7, OutputTokens: 2}, failedObserved.usage) + require.Equal(t, failedObserved.usage, state.usage) + require.Nil(t, state.pendingBareError) +} + +func TestObserveUpstreamMessageBareErrorBeforeNextCompletedKeepsBothTurns(t *testing.T) { + t.Parallel() + + now := time.Unix(400, 0) + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":5,"output_tokens":1}}`), "response.in_progress", nil) + bare := observeUpstreamMessage( + state, + []byte(`{"type":"error","error":{"message":"first turn failed"}}`), + now, + func() time.Time { return now }, + nil, + ) + require.False(t, bare.terminal) + bare = finalizePendingBareError(state, now) + require.True(t, bare.terminal) + require.Equal(t, Usage{InputTokens: 5, OutputTokens: 1}, bare.usage) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_next","usage":{"input_tokens":3,"output_tokens":2}}}`), + now, + func() time.Time { return now }, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 2}, completed.usage) + require.Equal(t, Usage{InputTokens: 8, OutputTokens: 3}, state.usage) +} + func TestOpenAICacheCreationTokensFromUsageNestedZeroWins(t *testing.T) { t.Parallel() @@ -363,7 +498,7 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { }) require.Equal(t, 0, called) - // 缺少 response_id 时不应触发。 + // 非 error 终态缺少 response_id 时不应触发。 emitTurnComplete(func(turn RelayTurnResult) { called++ }, &relayState{requestModel: "gpt-5"}, observedUpstreamEvent{ @@ -372,6 +507,22 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { }) require.Equal(t, 0, called) + // Bare error legitimately has no response_id; it must still settle the turn + // or a later completed turn would suppress the adapter's aggregate fallback. + var bareError RelayTurnResult + emitTurnComplete(func(turn RelayTurnResult) { + called++ + bareError = turn + }, &relayState{requestModel: "gpt-5"}, observedUpstreamEvent{ + terminal: true, + eventType: "error", + usage: Usage{InputTokens: 4, OutputTokens: 1}, + }) + require.Equal(t, 1, called) + require.Empty(t, bareError.RequestID) + require.Equal(t, "error", bareError.TerminalEventType) + require.Equal(t, Usage{InputTokens: 4, OutputTokens: 1}, bareError.Usage) + // terminal 且 response_id 存在,应该触发;state=nil 时 model 为空串。 var got RelayTurnResult emitTurnComplete(func(turn RelayTurnResult) { @@ -383,7 +534,7 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { responseID: "resp_emit", usage: Usage{InputTokens: 2, OutputTokens: 3}, }) - require.Equal(t, 1, called) + require.Equal(t, 2, called) require.Equal(t, "resp_emit", got.RequestID) require.Equal(t, "response.completed", got.TerminalEventType) require.Equal(t, 2, got.Usage.InputTokens) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index 12e5506698..3bca8bf0ab 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -472,6 +472,68 @@ func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) { require.Equal(t, 5, result.Usage.OutputTokens) } +func TestRelay_OnTurnComplete_BareErrorWithoutIDBeforeLaterCompleted(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"error","usage":{"input_tokens":5,"output_tokens":1},"error":{"message":"first turn failed"}}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_next","usage":{"input_tokens":3,"output_tokens":2}}}`), + }, + }, true) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make([]RelayTurnResult, 0, 2) + result, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`), + RelayOptions{OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }}, + ) + + require.Nil(t, relayExit) + require.Len(t, turns, 2) + require.Equal(t, "error", turns[0].TerminalEventType) + require.Empty(t, turns[0].RequestID) + require.Equal(t, Usage{InputTokens: 5, OutputTokens: 1}, turns[0].Usage) + require.Equal(t, "response.completed", turns[1].TerminalEventType) + require.Equal(t, "resp_next", turns[1].RequestID) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 2}, turns[1].Usage) + require.Equal(t, Usage{InputTokens: 8, OutputTokens: 3}, result.Usage) +} + +func TestRelay_OnTurnComplete_AuxiliaryFrameDoesNotSettleBareErrorBeforeFailed(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + {msgType: coderws.MessageText, payload: []byte(`{"type":"error","error":{"code":"server_error","message":"failed"}}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"rate_limits.updated","rate_limits":[{"name":"requests","remaining":1}]}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.failed","response":{"id":"resp_authoritative","usage":{"input_tokens":7,"output_tokens":2},"error":{"code":"server_error","message":"failed"}}}`)}, + }, true) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make([]RelayTurnResult, 0, 1) + result, relayExit := Relay(ctx, clientConn, upstreamConn, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`), RelayOptions{ + OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }, + }) + + require.Nil(t, relayExit) + require.Len(t, turns, 1) + require.Equal(t, "response.failed", turns[0].TerminalEventType) + require.Equal(t, "resp_authoritative", turns[0].RequestID) + require.Equal(t, Usage{InputTokens: 7, OutputTokens: 2}, turns[0].Usage) + require.Equal(t, turns[0].Usage, result.Usage) +} + func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 7303dfbb29..27cd4b44c3 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -86,11 +86,9 @@ func (c *openAIWSPolicyEnforcingFrameConn) Close() error { return c.inner.Close() } -// openAIWSPassthroughPolicyModelForFrame returns the upstream-perspective -// model name that should be passed to evaluateOpenAIFastPolicy for a single -// passthrough WS frame. Mirrors the HTTP-side normalization -// (account.GetMappedModel + normalizeOpenAIModelForUpstream) so the WS path -// matches model whitelists identically. +// openAIWSPassthroughPolicyModelForFrame returns the model actually forwarded +// by a passthrough WS frame. Passthrough only replaces authentication, so an +// account's ordinary model_mapping and Codex aliases must not rewrite it. func openAIWSPassthroughPolicyModelForFrame(account *Account, payload []byte) string { if account == nil || len(payload) == 0 { return "" @@ -99,7 +97,7 @@ func openAIWSPassthroughPolicyModelForFrame(account *Account, payload []byte) st if original == "" { return "" } - return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(original)) + return original } // openAIWSPassthroughPolicyModelFromSessionFrame returns the upstream model @@ -131,7 +129,7 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload [] if original == "" { return "" } - return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(original)) + return original } type openAIWSPassthroughUsageMeta struct { @@ -666,6 +664,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( hooks *OpenAIWSIngressHooks, wsDecision OpenAIWSProtocolDecision, ) error { + // The same request context survives scheduler failover attempts. Clear any + // reverse mapping installed by the prior account before processing frames. + setCodexToolNameReverse(c, nil) if s == nil { return errors.New("service is nil") } @@ -678,7 +679,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) if liteErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) @@ -733,21 +734,21 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } - if account.IsOpenAIOAuth() { - aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(firstClientMessage) - if aliasErr != nil { - return aliasErr - } - setCodexToolNameReverse(c, reverse) - if aliased { - firstClientMessage = aliasedBody - } - } if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized } + if account.IsOpenAIOAuthLike() { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(firstClientMessage) + if aliasErr != nil { + return aliasErr + } + updateCodexToolNameReverseForWSFrame(c, firstClientMessage, reverse) + if aliased { + firstClientMessage = aliasedBody + } + } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -786,6 +787,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // goroutine)和 OnTurnComplete / final result(runUpstreamToClient // goroutine)之间同步当前 turn 的 usage metadata。 usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel) + _, initialUpstreamModel := usageMeta.turnModels(initialRequestModel) + SetOpsUpstreamModel(c, initialUpstreamModel) promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) wsURL, err := s.buildOpenAIResponsesWSURL(account) @@ -882,10 +885,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( s.handleOpenAIWSDialTransientFailure(ctx, account, capturedSessionModel, dialErr) if statusCode == http.StatusTooManyRequests { s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error())) - return &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseHeaders: cloneHeader(handshakeHeaders), - } + return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, nil, err.Error()) } return s.mapOpenAIWSPassthroughDialError(err, statusCode, handshakeHeaders) } @@ -979,22 +979,24 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }() } if isResponseCreate { - if account.IsOpenAIOAuth() { - aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(payload) - if aliasErr != nil { - return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr) - } - setCodexToolNameReverse(c, reverse) - if aliased { - payload = aliasedBody - } - } if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized } - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(payload) { + } + if account.IsOpenAIOAuthLike() && (isResponseCreate || eventType == "session.update") { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(payload) + if aliasErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr) + } + updateCodexToolNameReverseForWSFrame(c, payload, reverse) + if aliased { + payload = aliasedBody + } + } + if isResponseCreate { + if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) { litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) @@ -1075,6 +1077,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // service_tier 时按 default 处理,billing 应如实反映。 if policyErr == nil && blocked == nil && isResponseCreate { usageMeta.updateFromResponseCreate(out, model, requestModelForThisFrame) + _, actualModel := usageMeta.turnModels(requestModelForThisFrame) + SetOpsUpstreamModel(c, actualModel) responseCreateAtCopy := responseCreateAt acceptedTurnStartedAt.Store(&responseCreateAtCopy) acceptedTurn = true @@ -1127,6 +1131,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if hooks != nil { firstTurnStartedAt = hooks.InitialTurnStartedAt } + failureAccountSideEffectsApplied := false relayResult, relayExit := openaiwsv2.RunEntry(openaiwsv2.EntryInput{ Ctx: ctx, ClientConn: policyClientConn, @@ -1209,6 +1214,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }, AfterClientWrite: func(msgType coderws.MessageType, payload []byte, writeErr error) { + if msgType == coderws.MessageText && writeErr == nil { + eventType, _, _ := parseOpenAIWSEventEnvelope(payload) + markOpenAIWSClientVisibleFailure(c, eventType, payload) + } if msgType == coderws.MessageText && openAIWSPassthroughIsTerminalOutput(payload) { turnLifecycle.finishTerminalWrite(writeErr == nil, clientFrameConn.markTurnCompleted) } @@ -1229,17 +1238,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return nil } eventType, _, _ := parseOpenAIWSEventEnvelope(payload) - if isOpenAIWSTerminalEvent(eventType) { - s.handleOpenAIWSTerminalTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) - } - if eventType == "error" { - s.handleOpenAIWSErrorEventTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) - } - if wroteDownstream || eventType != "error" { - return nil + if eventType == "response.created" { + failureAccountSideEffectsApplied = false } errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(payload) - if !isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { + isPreOutputRateLimit := eventType == "error" && !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) + if (eventType == "error" || eventType == "response.failed") && !failureAccountSideEffectsApplied && !isPreOutputRateLimit { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, capturedSessionModel, handshakeHeaders, payload) + } + if eventType != "error" { + return nil + } + if wroteDownstream || !isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { return nil } s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, payload, errCodeRaw, errTypeRaw, errMsgRaw) @@ -1250,11 +1260,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen), ) - return &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseBody: append([]byte(nil), payload...), - ResponseHeaders: cloneHeader(handshakeHeaders), - } + return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw) }, OnTrace: func(event openaiwsv2.RelayTraceEvent) { logOpenAIWSV2Passthrough( @@ -1272,6 +1278,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, }) if cause := context.Cause(ctx); cause != nil { + if isOpenAIWSSessionPreempted(ctx) { + return errOpenAIWSSessionPreempted + } status := coderws.StatusGoingAway reason := "websocket request canceled" if errors.Is(cause, ErrOpenAIWSIngressLeaseLost) { diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 35a97cc2f5..913bf992c1 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/gin-gonic/gin" ) @@ -15,6 +16,7 @@ const ( OpsUpstreamErrorMessageKey = "ops_upstream_error_message" OpsUpstreamErrorDetailKey = "ops_upstream_error_detail" OpsUpstreamErrorsKey = "ops_upstream_errors" + OpsUpstreamModelKey = "ops_upstream_model" // Optional stage latencies (milliseconds) for troubleshooting and alerting. OpsAuthLatencyMsKey = "ops_auth_latency_ms" @@ -36,7 +38,9 @@ const ( // 上就地(in-band)补发错误帧时记录的 OpsStreamError。因为 wire 状态码停留在 200, // ops_error_logger 的 status>=400 采集路径永远不会触发,这类流内失败 //(例如等待并发槽位超时后回退的限流、Wait 后二次计费校验失败)本会在错误看板里隐形。 - OpsStreamErrorKey = "ops_stream_error" + OpsStreamErrorKey = "ops_stream_error" + OpsStreamErrorsKey = "ops_stream_errors" + OpsStreamTurnKey = "ops_stream_turn" // Client-side configuration denials should remain visible in ops_error_logs, // but should be excluded from SLA/error-rate calculations. @@ -71,6 +75,26 @@ func SetOpsLatencyMs(c *gin.Context, key string, value int64) { c.Set(key, value) } +// SetOpsUpstreamModel stores only the effective model slug for final Ops +// attribution. Call it immediately before an upstream attempt is dispatched. +func SetOpsUpstreamModel(c *gin.Context, model string) { + if c == nil { + return + } + if model = strings.TrimSpace(model); model != "" { + c.Set(OpsUpstreamModelKey, model) + } +} + +// ClearOpsUpstreamModel invalidates attempt-scoped model attribution before a +// newly selected account starts credential resolution or upstream dispatch. +func ClearOpsUpstreamModel(c *gin.Context) { + if c == nil { + return + } + c.Set(OpsUpstreamModelKey, "") +} + func MarkOpsClientBusinessLimited(c *gin.Context, reason string) { if c == nil { return @@ -113,6 +137,32 @@ type OpsStreamError struct { // CountTowardsSLA 表示虽然 wire 状态已固化为 200,请求在应用语义上仍然失败, // Ops 应使用 IntendedStatus 计入错误率/SLA。 CountTowardsSLA bool + // Turn identifies a WebSocket turn. HTTP/SSE requests leave it at zero. + Turn int + // SkipMonitoring snapshots the rule decision for this visible failure. + SkipMonitoring bool + AccountID int64 + UpstreamModel string + UpstreamStatus int + UpstreamMessage string + UpstreamDetail string + UpstreamErrors []*OpsUpstreamErrorEvent +} + +const maxOpsStreamErrorsPerRequest = 64 + +// BeginOpsStreamTurn scopes first-wins deduplication to one WebSocket turn. +func BeginOpsStreamTurn(c *gin.Context, turn int) { + if c == nil || turn <= 0 { + return + } + c.Set(OpsStreamTurnKey, turn) + // Rule and attempt state is turn-scoped on a long-lived WS connection. + c.Set(OpsSkipPassthroughKey, false) + c.Set(OpsUpstreamErrorsKey, []*OpsUpstreamErrorEvent{}) + c.Set(OpsUpstreamStatusCodeKey, 0) + c.Set(OpsUpstreamErrorMessageKey, "") + c.Set(OpsUpstreamErrorDetailKey, "") } // MarkOpsStreamError 记录一次就地 SSE 错误,供 ops 日志采集。 @@ -143,15 +193,101 @@ func markOpsStreamError(c *gin.Context, streamErr OpsStreamError) { if c == nil { return } - if _, exists := c.Get(OpsStreamErrorKey); exists { - return - } streamErr.ErrType = strings.TrimSpace(streamErr.ErrType) streamErr.Code = strings.TrimSpace(streamErr.Code) streamErr.Message = strings.TrimSpace(streamErr.Message) + streamErr.SkipMonitoring = currentOpsFailureSkipMonitoring(c) + snapshotOpsStreamErrorContext(c, &streamErr) + if GetOpenAIClientTransport(c) == OpenAIClientTransportWS { + if value, ok := c.Get(OpsStreamTurnKey); ok { + streamErr.Turn, _ = value.(int) + } + var errorsForRequest []OpsStreamError + if value, ok := c.Get(OpsStreamErrorsKey); ok { + errorsForRequest, _ = value.([]OpsStreamError) + } + if len(errorsForRequest) > 0 && errorsForRequest[len(errorsForRequest)-1].Turn == streamErr.Turn { + return + } + errorsForRequest = append(errorsForRequest, streamErr) + if len(errorsForRequest) > maxOpsStreamErrorsPerRequest { + errorsForRequest = append([]OpsStreamError(nil), errorsForRequest[len(errorsForRequest)-maxOpsStreamErrorsPerRequest:]...) + } + c.Set(OpsStreamErrorsKey, errorsForRequest) + c.Set(OpsStreamErrorKey, streamErr) + return + } + if _, exists := c.Get(OpsStreamErrorKey); exists { + return + } c.Set(OpsStreamErrorKey, streamErr) } +func snapshotOpsStreamErrorContext(c *gin.Context, streamErr *OpsStreamError) { + if c == nil || streamErr == nil { + return + } + if c.Request != nil { + if accountID, ok := c.Request.Context().Value(ctxkey.AccountID).(int64); ok && accountID > 0 { + streamErr.AccountID = accountID + } + } + if value, ok := c.Get(OpsUpstreamModelKey); ok { + streamErr.UpstreamModel, _ = value.(string) + streamErr.UpstreamModel = strings.TrimSpace(streamErr.UpstreamModel) + } + if value, ok := c.Get(OpsUpstreamStatusCodeKey); ok { + switch status := value.(type) { + case int: + streamErr.UpstreamStatus = status + case int64: + streamErr.UpstreamStatus = int(status) + } + } + if value, ok := c.Get(OpsUpstreamErrorMessageKey); ok { + streamErr.UpstreamMessage, _ = value.(string) + streamErr.UpstreamMessage = strings.TrimSpace(streamErr.UpstreamMessage) + } + if value, ok := c.Get(OpsUpstreamErrorDetailKey); ok { + streamErr.UpstreamDetail, _ = value.(string) + streamErr.UpstreamDetail = strings.TrimSpace(streamErr.UpstreamDetail) + } + if value, ok := c.Get(OpsUpstreamErrorsKey); ok { + if events, ok := value.([]*OpsUpstreamErrorEvent); ok { + streamErr.UpstreamErrors = make([]*OpsUpstreamErrorEvent, 0, len(events)) + for _, event := range events { + if event == nil { + streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, nil) + continue + } + copyOfEvent := *event + streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, ©OfEvent) + } + } + } +} + +func currentOpsFailureSkipMonitoring(c *gin.Context) bool { + if c == nil { + return false + } + if value, ok := c.Get(OpsSkipPassthroughKey); ok { + if skip, _ := value.(bool); skip { + return true + } + } + if value, ok := c.Get(OpsUpstreamErrorsKey); ok { + if events, ok := value.([]*OpsUpstreamErrorEvent); ok { + for i := len(events) - 1; i >= 0; i-- { + if events[i] != nil { + return events[i].SkipMonitoring + } + } + } + } + return false +} + // GetOpsStreamError 返回本请求记录的就地 SSE 错误(若有)。 func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) { if c == nil { @@ -165,6 +301,21 @@ func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) { return se, ok } +func GetOpsStreamErrors(c *gin.Context) []OpsStreamError { + if c == nil { + return nil + } + if value, ok := c.Get(OpsStreamErrorsKey); ok { + if errorsForRequest, ok := value.([]OpsStreamError); ok && len(errorsForRequest) > 0 { + return append([]OpsStreamError(nil), errorsForRequest...) + } + } + if streamErr, ok := GetOpsStreamError(c); ok { + return []OpsStreamError{streamErr} + } + return nil +} + // SetOpsUpstreamError is the exported wrapper for setOpsUpstreamError, used by // handler-layer code (e.g. failover-exhausted paths) that needs to record the // original upstream status code before mapping it to a client-facing code. @@ -222,6 +373,10 @@ type OpsUpstreamErrorEvent struct { Message string `json:"message,omitempty"` Detail string `json:"detail,omitempty"` + + // SkipMonitoring is request-local rule state. It is intentionally excluded + // from persisted attempt JSON and only lets the final attempt control Ops. + SkipMonitoring bool `json:"-"` } func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { @@ -284,7 +439,7 @@ func checkSkipMonitoringForUpstreamEvent(c *gin.Context, ev *OpsUpstreamErrorEve rule := svc.MatchRule(ev.Platform, ev.UpstreamStatusCode, []byte(body)) if rule != nil && rule.SkipMonitoring { - c.Set(OpsSkipPassthroughKey, true) + ev.SkipMonitoring = true } } diff --git a/backend/internal/service/overload_cooldown_test.go b/backend/internal/service/overload_cooldown_test.go index ef5e7fd122..58dc13177c 100644 --- a/backend/internal/service/overload_cooldown_test.go +++ b/backend/internal/service/overload_cooldown_test.go @@ -269,6 +269,44 @@ func TestHandle529_DBReadError_FallsBackToConfig(t *testing.T) { require.WithinDuration(t, before.Add(7*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) } +func TestHandleUpstreamError_529BypassesPoolAndCustomCodeGates(t *testing.T) { + tests := []struct { + name string + credentials map[string]any + }{ + { + name: "pool mode", + credentials: map[string]any{"pool_mode": true}, + }, + { + name: "custom code filter excludes 529", + credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &overloadAccountRepoStub{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 101, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: tt.credentials, + } + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, 529, nil, []byte(`{"error":{"message":"overloaded"}}`)) + + require.False(t, shouldDisable) + require.Equal(t, 1, repo.overloadCalls) + require.Equal(t, account.ID, repo.lastOverloadID) + }) + } +} + // =========================================================================== // Model: defaults & JSON round-trip // =========================================================================== diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index c61e382d62..4e4068eb98 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -25,6 +25,7 @@ type RateLimitService struct { cfg *config.Config geminiQuotaService *GeminiQuotaService tempUnschedCache TempUnschedCache + openAIAPIKeyHealth OpenAIAPIKeyHealthCache timeoutCounterCache TimeoutCounterCache openAI403CounterCache OpenAI403CounterCache settingService *SettingService @@ -101,6 +102,10 @@ func (s *RateLimitService) SetTimeoutCounterCache(cache TimeoutCounterCache) { s.timeoutCounterCache = cache } +func (s *RateLimitService) SetOpenAIAPIKeyHealthCache(cache OpenAIAPIKeyHealthCache) { + s.openAIAPIKeyHealth = cache +} + // SetOpenAI403CounterCache 设置 OpenAI 403 连续失败计数器(可选依赖) func (s *RateLimitService) SetOpenAI403CounterCache(cache OpenAI403CounterCache) { s.openAI403CounterCache = cache @@ -247,6 +252,11 @@ const ( // 自定义错误码开启时覆盖后续所有逻辑(包括临时不可调度)。 func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Account, statusCode int, responseBody []byte, requestedModel ...string) ErrorPolicyResult { ctx = withTempUnschedulableModel(ctx, requestedModel) + // 529 is governed by the global overload cooldown. Return Matched before + // local pool/custom-code filters so every caller reaches HandleUpstreamError. + if statusCode == 529 { + return ErrorPolicyMatched + } if account.IsCustomErrorCodesEnabled() { if account.ShouldHandleErrorCode(statusCode) { return ErrorPolicyMatched @@ -277,6 +287,14 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc s.maybeHandleOpenAITeamLinkedError(ctx, account, statusCode, responseBody) customErrorCodesEnabled := account.IsCustomErrorCodesEnabled() + // The configured 529 cooldown is a global overload policy. Apply it before + // pool-mode and custom-code gates so those local retry policies cannot leave + // an overloaded account eligible for new requests. + if statusCode == 529 { + s.handle529(ctx, account) + return false + } + // 池模式默认不标记本地账号状态;但管理员显式配置的临时不可调度规则优先。 // 401 保留现有认证错误语义,不在这里改变池模式的认证处理。 if account.IsPoolMode() && !customErrorCodesEnabled { @@ -481,7 +499,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc s.handle429(ctx, account, headers, responseBody) shouldDisable = false case 529: - s.handle529(ctx, account) + // Handled before pool/custom-code policy gates above. shouldDisable = false default: // 自定义错误码启用时:在列表中的错误码都应该停止调度 @@ -1057,6 +1075,16 @@ func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *A // handle429 处理429限流错误 // 解析响应头获取重置时间,标记账号为限流状态 func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header, responseBody []byte) { + // OpenAI OAuth stays on the same account for the gateway's bounded retry + // window. Persisting a rate-limit reset on the first 429 would make the next + // retry ineligible and silently turn same-account recovery into a switch. + if account != nil && isOpenAIOAuthAccount(account) && s.runtimeBlocker != nil { + if checker, ok := s.runtimeBlocker.(interface { + ShouldRetryOpenAIOAuth429(*Account, http.Header, []byte) bool + }); ok && checker.ShouldRetryOpenAIOAuth429(account, headers, responseBody) { + return + } + } // Spark 影子:限流/熔断状态 100% 由 QueryUsage(/wham/usage body 的 codex_bengalfox)驱动。 // /responses 的 429 携带的 x-codex-*/usage_limit_reached 是 global codex 道(plan/spec §8), // 套到影子会把 spark 误耦合到 global 窗口——即便 spark 仍有配额也会被冷却到 global reset, diff --git a/backend/internal/service/setting_openai_apikey_health.go b/backend/internal/service/setting_openai_apikey_health.go new file mode 100644 index 0000000000..66886b5e8b --- /dev/null +++ b/backend/internal/service/setting_openai_apikey_health.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const openAIAPIKeyHealthBreakerSettingsCacheTTL = 30 * time.Second + +type cachedOpenAIAPIKeyHealthBreakerSettings struct { + settings OpenAIAPIKeyHealthBreakerSettings + expiresAt time.Time +} + +func normalizeOpenAIAPIKeyHealthBreakerSettings(settings *OpenAIAPIKeyHealthBreakerSettings) *OpenAIAPIKeyHealthBreakerSettings { + if settings == nil { + return DefaultOpenAIAPIKeyHealthBreakerSettings() + } + result := *settings + if result.WindowMinutes < 1 { + result.WindowMinutes = 1 + } else if result.WindowMinutes > 60 { + result.WindowMinutes = 60 + } + if result.FailureThreshold < 1 { + result.FailureThreshold = 1 + } else if result.FailureThreshold > 10000 { + result.FailureThreshold = 10000 + } + if result.CooldownMinutes < 1 { + result.CooldownMinutes = 1 + } else if result.CooldownMinutes > 60 { + result.CooldownMinutes = 60 + } + return &result +} + +func (s *SettingService) GetOpenAIAPIKeyHealthBreakerSettings(ctx context.Context) (*OpenAIAPIKeyHealthBreakerSettings, error) { + if s == nil || s.settingRepo == nil { + return DefaultOpenAIAPIKeyHealthBreakerSettings(), nil + } + if cached, ok := s.openAIAPIKeyHealthBreakerCache.Load().(*cachedOpenAIAPIKeyHealthBreakerSettings); ok && cached != nil && time.Now().Before(cached.expiresAt) { + result := cached.settings + return &result, nil + } + + settings := DefaultOpenAIAPIKeyHealthBreakerSettings() + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIAPIKeyHealthBreakerSettings) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return nil, fmt.Errorf("get OpenAI API key health breaker settings: %w", err) + } + if err == nil && strings.TrimSpace(value) != "" { + var stored OpenAIAPIKeyHealthBreakerSettings + if json.Unmarshal([]byte(value), &stored) == nil { + settings = normalizeOpenAIAPIKeyHealthBreakerSettings(&stored) + } + } + s.openAIAPIKeyHealthBreakerCache.Store(&cachedOpenAIAPIKeyHealthBreakerSettings{ + settings: *settings, + expiresAt: time.Now().Add(openAIAPIKeyHealthBreakerSettingsCacheTTL), + }) + result := *settings + return &result, nil +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f9091e8a27..06a1b19f1a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -149,6 +149,7 @@ type SettingService struct { // instance owns its own cache, no shared package-level state. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings openAIQuotaAutoPauseSettingsSF singleflight.Group + openAIAPIKeyHealthBreakerCache atomic.Value // *cachedOpenAIAPIKeyHealthBreakerSettings channelMonitorRuntimeListenersMu sync.Mutex channelMonitorRuntimeListeners []func() diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a939000947..25d48e7dca 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -563,6 +563,23 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// OpenAIAPIKeyHealthBreakerSettings controls cross-instance failure counting for OpenAI pool API keys. +type OpenAIAPIKeyHealthBreakerSettings struct { + Enabled bool `json:"enabled"` + WindowMinutes int `json:"window_minutes"` + FailureThreshold int `json:"failure_threshold"` + CooldownMinutes int `json:"cooldown_minutes"` +} + +func DefaultOpenAIAPIKeyHealthBreakerSettings() *OpenAIAPIKeyHealthBreakerSettings { + return &OpenAIAPIKeyHealthBreakerSettings{ + Enabled: false, + WindowMinutes: 2, + FailureThreshold: 10, + CooldownMinutes: 5, + } +} + // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { return &OverloadCooldownSettings{ diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index 06ae726df9..9b120c5775 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -25,6 +25,13 @@ type TempUnschedCache interface { DeleteTempUnsched(ctx context.Context, accountID int64) error } +// OpenAIAPIKeyHealthCache is an optional TempUnschedCache extension used to +// aggregate pool API-key failures across gateway instances. +type OpenAIAPIKeyHealthCache interface { + RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (count int64, tripped bool, err error) + ResetOpenAIAPIKeyHealthFailures(ctx context.Context, accountID int64) error +} + // TimeoutCounterCache 超时计数器缓存接口 type TimeoutCounterCache interface { // IncrementTimeoutCount 增加账户的超时计数,返回当前计数值 diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index f4e3bd4fe0..b7c304e488 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -124,8 +124,7 @@ func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) if observer == nil || strings.TrimSpace(body) == "" { return } - forEachOpenAISSEDataPayload(body, func(payload []byte) { - eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + forEachOpenAISSEFrame(body, func(eventType string, payload []byte) { observer.ObserveOpenAI(payload, eventType) }) } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 88c3a1f185..10467f944c 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -462,6 +462,9 @@ func ProvideRateLimitService( tokenCacheInvalidator TokenCacheInvalidator, ) *RateLimitService { svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache) + if healthCache, ok := tempUnschedCache.(OpenAIAPIKeyHealthCache); ok { + svc.SetOpenAIAPIKeyHealthCache(healthCache) + } svc.SetTimeoutCounterCache(timeoutCounterCache) svc.SetOpenAI403CounterCache(openAI403CounterCache) svc.SetSettingService(settingService)