From d665f5f7f66607a3ef3df2275cc3e6bc5dcefa5a Mon Sep 17 00:00:00 2001 From: PIXEL-AI-API <281688875+PIXEL-AI-API@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:42:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(openai):=20=E4=BC=98=E5=8C=96=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=B0=83=E5=BA=A6=E4=B8=8E=E5=93=8D=E5=BA=94=E9=80=8F?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 origin/develop 重新承载当前修复,补齐 OpenAI 账号调度、凭证脱敏、原始响应透传和相关前后端配置。 验证:git diff --check --cached;go test ./...;pnpm build。 --- .gitignore | 14 +- backend/cmd/server/VERSION | 2 +- backend/cmd/server/main.go | 9 +- backend/internal/domain/constants.go | 19 +- backend/internal/domain/constants_test.go | 26 +- .../internal/handler/admin/setting_handler.go | 12 + .../handler/dto/credentials_redact.go | 39 + backend/internal/handler/dto/mappers.go | 4 +- backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/dto/types.go | 49 +- .../handler/openai_chat_completions.go | 11 +- .../handler/openai_gateway_handler.go | 27 +- backend/internal/pkg/gemini/models.go | 2 + backend/internal/pkg/gemini/models_test.go | 12 +- backend/internal/pkg/geminicli/models.go | 2 + backend/internal/pkg/geminicli/models_test.go | 14 +- .../pkg/openai_compat/upstream_capability.go | 44 +- backend/internal/repository/account_repo.go | 18 + .../account_repo_compact_extra_test.go | 11 + .../account_repo_integration_test.go | 8 + backend/internal/repository/gateway_cache.go | 7 +- .../internal/repository/scheduler_cache.go | 2 + .../repository/scheduler_cache_unit_test.go | 4 + backend/internal/server/http.go | 48 +- .../service/account_credentials_redact.go | 59 + backend/internal/service/account_service.go | 11 +- backend/internal/service/admin_service.go | 6 +- backend/internal/service/billing_service.go | 9 +- backend/internal/service/domain_constants.go | 2 + .../service/error_passthrough_runtime_test.go | 4 +- .../gateway_hotpath_optimization_test.go | 2 +- .../service/gateway_multiplatform_test.go | 2 +- backend/internal/service/gateway_service.go | 2 + .../service/gemini_multiplatform_test.go | 2 +- .../service/openai_account_scheduler.go | 7 +- .../service/openai_account_scheduler_test.go | 101 +- ...enai_account_scheduler_ws_snapshot_test.go | 1 + .../internal/service/openai_clean_relay.go | 605 ++++ .../service/openai_clean_relay_test.go | 361 +++ .../service/openai_gateway_403_reset_test.go | 2 +- .../openai_gateway_chat_completions.go | 26 +- .../openai_gateway_chat_completions_raw.go | 338 +++ .../openai_gateway_chat_completions_test.go | 90 + .../service/openai_gateway_messages.go | 21 +- .../openai_gateway_record_usage_test.go | 58 + .../service/openai_gateway_service.go | 156 +- .../service/openai_gateway_service_test.go | 56 +- backend/internal/service/openai_images.go | 2 +- .../service/openai_images_responses.go | 2 +- .../service/openai_oauth_passthrough_test.go | 11 + .../service/openai_ws_account_sticky_test.go | 47 + .../internal/service/openai_ws_forwarder.go | 38 +- ...penai_ws_forwarder_ingress_session_test.go | 28 +- .../openai_ws_v2_passthrough_adapter.go | 23 +- backend/internal/service/ratelimit_service.go | 70 + .../service/ratelimit_service_401_test.go | 6 + .../service/ratelimit_service_openai_test.go | 79 + backend/internal/service/setting_service.go | 20 +- backend/internal/service/settings_view.go | 1 + ...43\345\206\263\346\226\271\346\241\210.md" | 2588 +++++++++++++++++ frontend/src/api/admin/settings.ts | 2 + .../components/account/EditAccountModal.vue | 11 +- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/types/index.ts | 1 + frontend/src/views/admin/SettingsView.vue | 18 + 66 files changed, 5071 insertions(+), 186 deletions(-) create mode 100644 backend/internal/handler/dto/credentials_redact.go create mode 100644 backend/internal/service/account_credentials_redact.go create mode 100644 backend/internal/service/openai_clean_relay.go create mode 100644 backend/internal/service/openai_clean_relay_test.go create mode 100644 backend/internal/service/openai_gateway_chat_completions_raw.go create mode 100644 "docs/Pixel\345\244\232\345\256\236\344\276\213\350\247\243\345\206\263\346\226\271\346\241\210.md" diff --git a/.gitignore b/.gitignore index b69df12a1..8ca75aa83 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,7 @@ frontend/node_modules/ frontend/dist/ *.local *.tsbuildinfo -vite.config.d.ts +frontend/vite.config.d.ts vite.config.js.timestamp-* # 日志 @@ -136,10 +136,8 @@ backend/.installed # =================== # 其他 # =================== -tests CLAUDE.md .claude -scripts .code-review-state #openspec/ code-reviews/ @@ -147,13 +145,9 @@ AGENTS.md backend/cmd/server/server deploy/docker-compose.override.yml .gocache/ -vite.config.js -docs/* -!docs/PAYMENT.md -!docs/PAYMENT_CN.md -!docs/ADMIN_PAYMENT_INTEGRATION_API.md -!docs/OFFICIAL_UPDATE_AND_DEPLOY_CN.md -!docs/LOCAL_IMAGE_UPLOAD_DEPLOY_CN.md +frontend/vite.config.js +docs/private/ +docs/服务器项目信息.md .serena/ .codex/ frontend/coverage/ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index adb7b04cb..4c24bf133 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -1.0.27 +1.0.29 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6eb85e3b1..96d46a3ae 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -29,8 +29,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/web" "github.com/gin-gonic/gin" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. ) //go:embed VERSION @@ -121,11 +119,16 @@ func runSetupServer() { log.Printf("Setup wizard available at http://%s", addr) log.Println("Complete the setup wizard to configure Sub2API") + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ Addr: addr, - Handler: h2c.NewHandler(r, &http2.Server{}), //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. + Handler: r, ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, + Protocols: protocols, } if err := serveServer(server, config.ServerListenSpec{ diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 27431f2fb..e23a3bf59 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -103,10 +103,12 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-haiku-4-5": "claude-sonnet-4-6", "claude-haiku-4-5-20251001": "claude-sonnet-4-6", // Gemini 2.5 白名单 - "gemini-2.5-flash": "gemini-2.5-flash", - "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", - "gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking", - "gemini-2.5-pro": "gemini-2.5-pro", + "gemini-2.5-flash": "gemini-2.5-flash", + "gemini-2.5-flash-image": "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview": "gemini-2.5-flash-image", + "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", + "gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking", + "gemini-2.5-pro": "gemini-2.5-pro", // Gemini 3 白名单 "gemini-3-flash": "gemini-3-flash", "gemini-3-pro-high": "gemini-3-pro-high", @@ -115,10 +117,15 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-preview": "gemini-3-pro-high", // Gemini 3.1 白名单 - "gemini-3.1-pro-high": "gemini-3.1-pro-high", - "gemini-3.1-pro-low": "gemini-3.1-pro-low", + "gemini-3.1-flash-image": "gemini-3.1-flash-image", + "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image", + "gemini-3.1-pro-high": "gemini-3.1-pro-high", + "gemini-3.1-pro-low": "gemini-3.1-pro-low", // Gemini 3.1 preview 映射 "gemini-3.1-pro-preview": "gemini-3.1-pro-high", + // Gemini image compatibility aliases + "gemini-3-pro-image": "gemini-3.1-flash-image", + "gemini-3-pro-image-preview": "gemini-3.1-flash-image", // 其他官方模型 "gpt-oss-120b-medium": "gpt-oss-120b-medium", "tab_flash_lite_preview": "tab_flash_lite_preview", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index d2957d98b..94be8f0b8 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -2,21 +2,25 @@ package domain import "testing" -func TestDefaultAntigravityModelMapping_ExcludesImageCompatibilityAliases(t *testing.T) { +func TestDefaultAntigravityModelMapping_IncludesImageCompatibilityAliases(t *testing.T) { t.Parallel() - blocked := []string{ - "gemini-2.5-flash-image", - "gemini-2.5-flash-image-preview", - "gemini-3.1-flash-image", - "gemini-3.1-flash-image-preview", - "gemini-3-pro-image", - "gemini-3-pro-image-preview", + expected := map[string]string{ + "gemini-2.5-flash-image": "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview": "gemini-2.5-flash-image", + "gemini-3.1-flash-image": "gemini-3.1-flash-image", + "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image", + "gemini-3-pro-image": "gemini-3.1-flash-image", + "gemini-3-pro-image-preview": "gemini-3.1-flash-image", } - for _, model := range blocked { - if got, ok := DefaultAntigravityModelMapping[model]; ok { - t.Fatalf("did not expect image generation model %q in default mapping, got %q", model, got) + for model, want := range expected { + got, ok := DefaultAntigravityModelMapping[model] + if !ok { + t.Fatalf("expected image generation model %q in default mapping", model) + } + if got != want { + t.Fatalf("DefaultAntigravityModelMapping[%q] = %q, want %q", model, got, want) } } } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a629bb723..c15a7b192 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -260,6 +260,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableFingerprintUnification: settings.EnableFingerprintUnification, EnableMetadataPassthrough: settings.EnableMetadataPassthrough, EnableCCHSigning: settings.EnableCCHSigning, + OpenAICleanRelayEnabled: settings.OpenAICleanRelayEnabled, EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, @@ -575,6 +576,7 @@ type UpdateSettingsRequest struct { EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` EnableCCHSigning *bool `json:"enable_cch_signing"` + OpenAICleanRelayEnabled *bool `json:"openai_clean_relay_enabled"` EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` // Payment visible method routing @@ -1547,6 +1549,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableCCHSigning }(), + OpenAICleanRelayEnabled: func() bool { + if req.OpenAICleanRelayEnabled != nil { + return *req.OpenAICleanRelayEnabled + } + return previousSettings.OpenAICleanRelayEnabled + }(), EnableAnthropicCacheTTL1hInjection: func() bool { if req.EnableAnthropicCacheTTL1hInjection != nil { return *req.EnableAnthropicCacheTTL1hInjection @@ -1915,6 +1923,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, EnableCCHSigning: updatedSettings.EnableCCHSigning, + OpenAICleanRelayEnabled: updatedSettings.OpenAICleanRelayEnabled, EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, @@ -2680,6 +2689,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EnableCCHSigning != after.EnableCCHSigning { changed = append(changed, "enable_cch_signing") } + if before.OpenAICleanRelayEnabled != after.OpenAICleanRelayEnabled { + changed = append(changed, "openai_clean_relay_enabled") + } if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection { changed = append(changed, "enable_anthropic_cache_ttl_1h_injection") } diff --git a/backend/internal/handler/dto/credentials_redact.go b/backend/internal/handler/dto/credentials_redact.go new file mode 100644 index 000000000..fd22eea8a --- /dev/null +++ b/backend/internal/handler/dto/credentials_redact.go @@ -0,0 +1,39 @@ +package dto + +import "github.com/Wei-Shaw/sub2api/internal/service" + +// RedactCredentials returns a copy of credentials without sensitive keys plus +// has_ status flags so the UI can tell configured secrets from missing ones. +func RedactCredentials(in map[string]any) (map[string]any, map[string]bool) { + if in == nil { + return nil, nil + } + out := make(map[string]any, len(in)) + var status map[string]bool + for key, value := range in { + if service.IsSensitiveCredentialKey(key) { + if isCredentialValuePresent(value) { + if status == nil { + status = make(map[string]bool, 4) + } + status["has_"+key] = true + } + continue + } + out[key] = value + } + return out, status +} + +func isCredentialValuePresent(value any) bool { + switch v := value.(type) { + case nil: + return false + case string: + return v != "" + case bool: + return v + default: + return true + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1c1a65e19..e21505543 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -232,6 +232,7 @@ func AccountFromServiceShallow(a *service.Account) *Account { if a == nil { return nil } + redactedCredentials, credentialsStatus := RedactCredentials(a.Credentials) out := &Account{ ID: a.ID, Name: a.Name, @@ -239,7 +240,8 @@ func AccountFromServiceShallow(a *service.Account) *Account { Platform: a.Platform, AccountLevel: service.NormalizeAccountLevel(a.AccountLevel), Type: a.Type, - Credentials: a.Credentials, + Credentials: redactedCredentials, + CredentialsStatus: credentialsStatus, Extra: a.Extra, OwnerUserID: a.OwnerUserID, ShareMode: service.NormalizeAccountShareMode(a.ShareMode), diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fb3034851..c365f9a99 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -178,6 +178,7 @@ type SystemSettings struct { EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` EnableCCHSigning bool `json:"enable_cch_signing"` + OpenAICleanRelayEnabled bool `json:"openai_clean_relay_enabled"` EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` // Web Search Emulation diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb160d2c..3620f2e62 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -168,30 +168,31 @@ type AdminGroup struct { } type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - AccountLevel string `json:"account_level"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - OwnerUserID *int64 `json:"owner_user_id,omitempty"` - ShareMode string `json:"share_mode"` - ShareStatus string `json:"share_status"` - SharePolicyID *int64 `json:"share_policy_id,omitempty"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - LoadFactor *int `json:"load_factor,omitempty"` - Priority int `json:"priority"` - RateMultiplier float64 `json:"rate_multiplier"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - ExpiresAt *int64 `json:"expires_at"` - AutoPauseOnExpired bool `json:"auto_pause_on_expired"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + AccountLevel string `json:"account_level"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra"` + OwnerUserID *int64 `json:"owner_user_id,omitempty"` + ShareMode string `json:"share_mode"` + ShareStatus string `json:"share_status"` + SharePolicyID *int64 `json:"share_policy_id,omitempty"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 143eca95f..623b714c7 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -147,15 +147,19 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { zap.Int("excluded_account_count", len(failedAccountIDs)), zap.Int64p("group_id", currentAPIKey.GroupID), ) - selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler( + selectionModel := resolveOpenAIAccountSelectionModel(reqModel, channelMapping) + selection, scheduleDecision, err := h.gatewayService.SelectAccountWithCleanRelayScheduler( c.Request.Context(), + c, currentAPIKey.GroupID, "", sessionHash, reqModel, + selectionModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, false, + body, ) if err != nil { reqLog.Warn("openai_chat_completions.account_select_failed", @@ -172,15 +176,18 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { reqLog.Info("openai_chat_completions.fallback_to_default_model", zap.String("default_mapped_model", defaultModel), ) - selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler( + selection, scheduleDecision, err = h.gatewayService.SelectAccountWithCleanRelayScheduler( c.Request.Context(), + c, currentAPIKey.GroupID, "", sessionHash, defaultModel, + defaultModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, false, + body, ) if err == nil && selection != nil { c.Set("openai_chat_completions_fallback_model", defaultModel) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index a772a41cd..cb5c89d32 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -55,6 +55,15 @@ func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedM return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) } +func resolveOpenAIAccountSelectionModel(requestedModel string, mapping service.ChannelMappingResult) string { + if mapping.Mapped { + if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" { + return mappedModel + } + } + return strings.TrimSpace(requestedModel) +} + // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, @@ -276,15 +285,19 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { zap.Int("excluded_account_count", len(failedAccountIDs)), zap.Int64p("group_id", currentAPIKey.GroupID), ) - selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler( + selectionModel := resolveOpenAIAccountSelectionModel(reqModel, channelMapping) + selection, scheduleDecision, err := h.gatewayService.SelectAccountWithCleanRelayScheduler( c.Request.Context(), + c, currentAPIKey.GroupID, previousResponseID, sessionHash, reqModel, + selectionModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, requireCompact, + sessionHashBody, ) if err != nil { reqLog.Warn("openai.account_select_failed", @@ -724,19 +737,23 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { if effectiveMappedModel != "" { currentRoutingModel = effectiveMappedModel } + currentRoutingModel = resolveOpenAIAccountSelectionModel(currentRoutingModel, channelMappingMsg) reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)), zap.Int64p("group_id", currentAPIKey.GroupID), ) - selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler( + selection, scheduleDecision, err := h.gatewayService.SelectAccountWithCleanRelayScheduler( c.Request.Context(), + c, currentAPIKey.GroupID, "", // no previous_response_id sessionHash, + reqModel, currentRoutingModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, false, + body, ) if err != nil { reqLog.Warn("openai_messages.account_select_failed", @@ -1326,15 +1343,19 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } var selectErr error - selection, scheduleDecision, selectErr = h.gatewayService.SelectAccountWithScheduler( + selectionModel := resolveOpenAIAccountSelectionModel(reqModel, channelMappingWS) + selection, scheduleDecision, selectErr = h.gatewayService.SelectAccountWithCleanRelayScheduler( ctx, + c, currentAPIKey.GroupID, previousResponseID, sessionHash, reqModel, + selectionModel, nil, service.OpenAIUpstreamTransportResponsesWebsocketV2, false, + firstMessage, ) if selectErr == nil && selection != nil && selection.Account != nil { break diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index f2c464166..7c5ac696c 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -20,8 +20,10 @@ func DefaultModels() []Model { return []Model{ {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.5-flash-image", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-3.1-flash-image", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-pro-preview-customtools", SupportedGenerationMethods: methods}, diff --git a/backend/internal/pkg/gemini/models_test.go b/backend/internal/pkg/gemini/models_test.go index 476137c74..4816139f2 100644 --- a/backend/internal/pkg/gemini/models_test.go +++ b/backend/internal/pkg/gemini/models_test.go @@ -22,13 +22,17 @@ func TestDefaultModels_ContainsFallbackCatalogModels(t *testing.T) { } } - blocked := []string{ + imageModels := []string{ "models/gemini-2.5-flash-image", "models/gemini-3.1-flash-image", } - for _, name := range blocked { - if _, ok := byName[name]; ok { - t.Fatalf("did not expect fallback image generation model %q to exist", name) + for _, name := range imageModels { + model, ok := byName[name] + if !ok { + t.Fatalf("expected fallback image generation model %q to exist", name) + } + if len(model.SupportedGenerationMethods) == 0 { + t.Fatalf("expected fallback image generation model %q to advertise generation methods", name) } } } diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index 1fc4d983c..3a3bd86f3 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -13,8 +13,10 @@ type Model struct { var DefaultModels = []Model{ {ID: "gemini-2.0-flash", Type: "model", DisplayName: "Gemini 2.0 Flash", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, + {ID: "gemini-2.5-flash-image", Type: "model", DisplayName: "Gemini 2.5 Flash Image", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""}, + {ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""}, {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""}, {ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""}, } diff --git a/backend/internal/pkg/geminicli/models_test.go b/backend/internal/pkg/geminicli/models_test.go index 85d7cfc0f..c591c4820 100644 --- a/backend/internal/pkg/geminicli/models_test.go +++ b/backend/internal/pkg/geminicli/models_test.go @@ -2,7 +2,7 @@ package geminicli import "testing" -func TestDefaultModels_ExcludesImageModels(t *testing.T) { +func TestDefaultModels_IncludesImageModels(t *testing.T) { t.Parallel() byID := make(map[string]Model, len(DefaultModels)) @@ -10,14 +10,18 @@ func TestDefaultModels_ExcludesImageModels(t *testing.T) { byID[model.ID] = model } - blocked := []string{ + required := []string{ "gemini-2.5-flash-image", "gemini-3.1-flash-image", } - for _, id := range blocked { - if _, ok := byID[id]; ok { - t.Fatalf("did not expect curated Gemini image model %q to exist", id) + for _, id := range required { + model, ok := byID[id] + if !ok { + t.Fatalf("expected curated Gemini image model %q to exist", id) + } + if model.DisplayName == "" { + t.Fatalf("expected curated Gemini image model %q to have a display name", id) } } } diff --git a/backend/internal/pkg/openai_compat/upstream_capability.go b/backend/internal/pkg/openai_compat/upstream_capability.go index ff05afe55..1883a65ca 100644 --- a/backend/internal/pkg/openai_compat/upstream_capability.go +++ b/backend/internal/pkg/openai_compat/upstream_capability.go @@ -17,7 +17,7 @@ // pensieve/short-term/maxims/preserve-existing-runtime-behavior-when-replacing-logic-in-stateful-systems) package openai_compat -// AccountResponsesSupport 描述账号上游对 OpenAI Responses API 的支持状态。 +// AccountResponsesSupport 描述账号上游对 OpenAI Responses API 的有效支持状态。 // // 仅用于 platform=openai + type=apikey 的账号;其他账号类型不应调用本包判定。 type AccountResponsesSupport int @@ -35,11 +35,43 @@ const ( ResponsesSupportNo ) +// ResponsesSupportMode 描述账号级 Responses API 路由覆盖模式。 +type ResponsesSupportMode string + +const ( + // ResponsesSupportModeAuto 表示跟随自动探测结果。 + ResponsesSupportModeAuto ResponsesSupportMode = "auto" + + // ResponsesSupportModeForceResponses 强制使用 /v1/responses。 + ResponsesSupportModeForceResponses ResponsesSupportMode = "force_responses" + + // ResponsesSupportModeForceChatCompletions 强制使用 /v1/chat/completions。 + ResponsesSupportModeForceChatCompletions ResponsesSupportMode = "force_chat_completions" +) + +// ExtraKeyResponsesMode 是 accounts.extra JSON 中存储手动覆盖模式的键名。 +// 值类型为 string:auto=跟随探测,force_responses=强制 Responses, +// force_chat_completions=强制 Chat Completions。 +const ExtraKeyResponsesMode = "openai_responses_mode" + // ExtraKeyResponsesSupported 是 accounts.extra JSON 中存储探测结果的键名。 // 值类型为 bool:true=支持、false=不支持、键缺失=未探测。 const ExtraKeyResponsesSupported = "openai_responses_supported" -// ResolveResponsesSupport 从账号的 extra map 中读取探测标记。 +// NormalizeResponsesSupportMode 归一化账号级 Responses API 路由覆盖模式。 +// 缺失或非法值按 auto 处理,以保持存量行为。 +func NormalizeResponsesSupportMode(mode string) ResponsesSupportMode { + switch ResponsesSupportMode(mode) { + case ResponsesSupportModeForceResponses: + return ResponsesSupportModeForceResponses + case ResponsesSupportModeForceChatCompletions: + return ResponsesSupportModeForceChatCompletions + default: + return ResponsesSupportModeAuto + } +} + +// ResolveResponsesSupport 从账号的 extra map 中读取手动覆盖模式与探测标记。 // // 标记缺失或类型不匹配时返回 ResponsesSupportUnknown——调用方应按 // "未探测=保留旧行为=走 Responses" 处理(参见 ShouldUseResponsesAPI)。 @@ -47,6 +79,14 @@ func ResolveResponsesSupport(extra map[string]any) AccountResponsesSupport { if extra == nil { return ResponsesSupportUnknown } + if mode, ok := extra[ExtraKeyResponsesMode].(string); ok { + switch NormalizeResponsesSupportMode(mode) { + case ResponsesSupportModeForceResponses: + return ResponsesSupportYes + case ResponsesSupportModeForceChatCompletions: + return ResponsesSupportNo + } + } v, ok := extra[ExtraKeyResponsesSupported] if !ok { return ResponsesSupportUnknown diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 13bc1f5ef..97d08b054 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -67,6 +67,11 @@ var schedulerNeutralExtraKeys = map[string]struct{}{ "session_window_utilization": {}, } +var schedulerRelevantExtraKeys = map[string]struct{}{ + "openai_responses_mode": {}, + "openai_responses_supported": {}, +} + // NewAccountRepository 创建账户仓储实例。 // 这是对外暴露的构造函数,返回接口类型以便于依赖注入。 func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository { @@ -485,6 +490,7 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error { return err } } + r.deleteSchedulerAccountSnapshot(ctx, id) if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, buildSchedulerGroupPayload(groupIDs)); err != nil { logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue account delete failed: account=%d err=%v", id, err) } @@ -1100,6 +1106,15 @@ func (r *accountRepository) syncSchedulerAccountSnapshot(ctx context.Context, ac } } +func (r *accountRepository) deleteSchedulerAccountSnapshot(ctx context.Context, accountID int64) { + if r == nil || r.schedulerCache == nil || accountID <= 0 { + return + } + if err := r.schedulerCache.DeleteAccount(ctx, accountID); err != nil { + logger.LegacyPrintf("repository.account", "[Scheduler] delete account snapshot failed: id=%d err=%v", accountID, err) + } +} + func (r *accountRepository) syncSchedulerAccountSnapshots(ctx context.Context, accountIDs []int64) { if r == nil || r.schedulerCache == nil || len(accountIDs) == 0 { return @@ -1704,6 +1719,9 @@ func shouldEnqueueSchedulerOutboxForExtraUpdates(updates map[string]any) bool { return false } for key := range updates { + if _, ok := schedulerRelevantExtraKeys[strings.TrimSpace(key)]; ok { + return true + } if isCodexQuotaLimitExtraKey(key) { return true } diff --git a/backend/internal/repository/account_repo_compact_extra_test.go b/backend/internal/repository/account_repo_compact_extra_test.go index 24fb0fbbd..f7fb62dfd 100644 --- a/backend/internal/repository/account_repo_compact_extra_test.go +++ b/backend/internal/repository/account_repo_compact_extra_test.go @@ -22,3 +22,14 @@ func TestShouldEnqueueSchedulerOutboxForExtraUpdates_CodexLimitKeysAreRelevant(t t.Fatalf("expected codex limit updates to enqueue scheduler outbox") } } + +func TestShouldEnqueueSchedulerOutboxForExtraUpdates_OpenAIResponsesRoutingKeysAreRelevant(t *testing.T) { + updates := map[string]any{ + "openai_responses_mode": "force_chat_completions", + "openai_responses_supported": false, + } + + if !shouldEnqueueSchedulerOutboxForExtraUpdates(updates) { + t.Fatalf("expected OpenAI Responses routing updates to enqueue scheduler outbox") + } +} diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index 60b3558d7..2618cdaa0 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -24,6 +24,7 @@ type AccountRepoSuite struct { type schedulerCacheRecorder struct { setAccounts []*service.Account + deletedIDs []int64 accounts map[int64]*service.Account } @@ -54,6 +55,10 @@ func (s *schedulerCacheRecorder) SetAccount(ctx context.Context, account *servic } func (s *schedulerCacheRecorder) DeleteAccount(ctx context.Context, accountID int64) error { + s.deletedIDs = append(s.deletedIDs, accountID) + if s.accounts != nil { + delete(s.accounts, accountID) + } return nil } @@ -178,9 +183,12 @@ func (s *AccountRepoSuite) TestUpdate_SyncSchedulerSnapshotOnCredentialsChange() func (s *AccountRepoSuite) TestDelete() { account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "to-delete"}) + cacheRecorder := &schedulerCacheRecorder{} + s.repo.schedulerCache = cacheRecorder err := s.repo.Delete(s.ctx, account.ID) s.Require().NoError(err, "Delete") + s.Require().Equal([]int64{account.ID}, cacheRecorder.deletedIDs) _, err = s.repo.GetByID(s.ctx, account.ID) s.Require().Error(err, "expected error after delete") diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index 9db2ee1c5..3ffb21dd3 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "time" @@ -54,7 +55,11 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64 func (c *gatewayCache) GetSessionString(ctx context.Context, groupID int64, sessionHash string) (string, error) { key := buildSessionKey(groupID, sessionHash) - return c.rdb.Get(ctx, key).Result() + value, err := c.rdb.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", fmt.Errorf("%w: %w", service.ErrGatewaySessionStringNotFound, err) + } + return value, err } func (c *gatewayCache) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 7abe2ea56..1a18fded4 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -578,6 +578,8 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "responses_websockets_v2_enabled", "openai_ws_enabled", "openai_ws_force_http", + "openai_responses_mode", + "openai_responses_supported", "codex_5h_used_percent", "codex_5h_reset_at", "codex_5h_reset_after_seconds", diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index 33f3b581b..86de87c70 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -18,6 +18,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { "openai_oauth_responses_websockets_v2_enabled": true, "openai_oauth_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, "openai_ws_force_http": true, + "openai_responses_mode": "force_chat_completions", + "openai_responses_supported": false, "mixed_scheduling": true, "unused_large_field": "drop-me", }, @@ -28,6 +30,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { require.Equal(t, true, got.Extra["openai_oauth_responses_websockets_v2_enabled"]) require.Equal(t, service.OpenAIWSIngressModePassthrough, got.Extra["openai_oauth_responses_websockets_v2_mode"]) require.Equal(t, true, got.Extra["openai_ws_force_http"]) + require.Equal(t, "force_chat_completions", got.Extra["openai_responses_mode"]) + require.Equal(t, false, got.Extra["openai_responses_supported"]) require.Equal(t, true, got.Extra["mixed_scheduling"]) require.Nil(t, got.Extra["unused_large_field"]) } diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 9883b176b..aa7888b73 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -18,7 +18,6 @@ import ( "github.com/google/wire" "github.com/redis/go-redis/v9" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. ) // ProviderSet 提供服务器层的依赖 @@ -101,6 +100,16 @@ func ProvideRouter( // ProvideHTTPServer 提供 HTTP 服务器 func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { httpHandler := http.Handler(router) + server := &http.Server{ + Addr: cfg.Server.Address(), + Handler: httpHandler, + // ReadHeaderTimeout: 读取请求头的超时时间,防止慢速请求头攻击 + ReadHeaderTimeout: time.Duration(cfg.Server.ReadHeaderTimeout) * time.Second, + // IdleTimeout: 空闲连接超时时间,释放不活跃的连接资源 + IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second, + // 注意:不设置 WriteTimeout,因为流式响应可能持续十几分钟 + // 不设置 ReadTimeout,因为大请求体可能需要较长时间读取 + } globalMaxSize := cfg.Server.MaxRequestBodySize if globalMaxSize <= 0 { @@ -114,32 +123,31 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { // 根据配置决定是否启用 H2C if cfg.Server.H2C.Enabled { h2cConfig := cfg.Server.H2C - httpHandler = h2c.NewHandler(router, &http2.Server{ //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. + if err := http2.ConfigureServer(server, &http2.Server{ MaxConcurrentStreams: h2cConfig.MaxConcurrentStreams, IdleTimeout: time.Duration(h2cConfig.IdleTimeout) * time.Second, MaxReadFrameSize: uint32(h2cConfig.MaxReadFrameSize), MaxUploadBufferPerConnection: int32(h2cConfig.MaxUploadBufferPerConnection), MaxUploadBufferPerStream: int32(h2cConfig.MaxUploadBufferPerStream), - }) - log.Printf("HTTP/2 Cleartext (h2c) enabled: max_concurrent_streams=%d, idle_timeout=%ds, max_read_frame_size=%d, max_upload_buffer_per_connection=%d, max_upload_buffer_per_stream=%d", - h2cConfig.MaxConcurrentStreams, - h2cConfig.IdleTimeout, - h2cConfig.MaxReadFrameSize, - h2cConfig.MaxUploadBufferPerConnection, - h2cConfig.MaxUploadBufferPerStream, - ) + }); err != nil { + log.Printf("Failed to configure HTTP/2 Cleartext (h2c): %v", err) + } else { + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server.Protocols = protocols + log.Printf("HTTP/2 Cleartext (h2c) enabled: max_concurrent_streams=%d, idle_timeout=%ds, max_read_frame_size=%d, max_upload_buffer_per_connection=%d, max_upload_buffer_per_stream=%d", + h2cConfig.MaxConcurrentStreams, + h2cConfig.IdleTimeout, + h2cConfig.MaxReadFrameSize, + h2cConfig.MaxUploadBufferPerConnection, + h2cConfig.MaxUploadBufferPerStream, + ) + } } - return &http.Server{ - Addr: cfg.Server.Address(), - Handler: httpHandler, - // ReadHeaderTimeout: 读取请求头的超时时间,防止慢速请求头攻击 - ReadHeaderTimeout: time.Duration(cfg.Server.ReadHeaderTimeout) * time.Second, - // IdleTimeout: 空闲连接超时时间,释放不活跃的连接资源 - IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second, - // 注意:不设置 WriteTimeout,因为流式响应可能持续十几分钟 - // 不设置 ReadTimeout,因为大请求体可能需要较长时间读取 - } + server.Handler = httpHandler + return server } func derefInt64(p *int64) int64 { diff --git a/backend/internal/service/account_credentials_redact.go b/backend/internal/service/account_credentials_redact.go new file mode 100644 index 000000000..3d4d360fe --- /dev/null +++ b/backend/internal/service/account_credentials_redact.go @@ -0,0 +1,59 @@ +package service + +// SensitiveCredentialKeys lists Account.Credentials keys that must never be +// returned to frontend responses. DTO redaction and update merge logic share +// this list so new credential types stay consistent. +var SensitiveCredentialKeys = []string{ + // OAuth tokens + "access_token", + "refresh_token", + "id_token", + // API key and browser-session credentials + "api_key", + "session_key", + "session_token", + "claude_session_key", + "cookie", + "cookies", + // Cloud credentials + "aws_secret_access_key", + "aws_session_token", + "service_account_json", + "service_account", + "private_key", +} + +var sensitiveCredentialKeySet = func() map[string]struct{} { + keys := make(map[string]struct{}, len(SensitiveCredentialKeys)) + for _, key := range SensitiveCredentialKeys { + keys[key] = struct{}{} + } + return keys +}() + +func IsSensitiveCredentialKey(key string) bool { + _, ok := sensitiveCredentialKeySet[key] + return ok +} + +// MergePreservingSensitiveCreds applies incoming over existing, but preserves +// existing sensitive values when the incoming payload omits them. This protects +// full-object edit flows after response DTOs stop returning raw secrets. +func MergePreservingSensitiveCreds(existing, incoming map[string]any) map[string]any { + if len(existing) == 0 && len(incoming) == 0 { + return nil + } + out := make(map[string]any, len(incoming)+len(SensitiveCredentialKeys)) + for key, value := range incoming { + out[key] = value + } + for _, key := range SensitiveCredentialKeys { + if _, ok := incoming[key]; ok { + continue + } + if value, ok := existing[key]; ok { + out[key] = value + } + } + return out +} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 0ef61f16d..511e58ed3 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -746,7 +746,7 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount } if req.Credentials != nil { - account.Credentials = *req.Credentials + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, *req.Credentials) } if req.Extra != nil { @@ -856,7 +856,7 @@ func (s *AccountService) UpdateOwned(ctx context.Context, ownerUserID, accountID account.Notes = normalizeAccountNotes(req.Notes) } if req.Credentials != nil { - account.Credentials = *req.Credentials + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, *req.Credentials) } if req.Extra != nil { extra, err := NormalizeCodexQuotaLimitExtra(account.Platform, account.Type, *req.Extra) @@ -1119,6 +1119,13 @@ func mergeAccountMap(current map[string]any, updates map[string]any) map[string] return next } +func mergeAccountMapPreservingSensitiveCreds(current map[string]any, updates map[string]any) map[string]any { + if len(updates) == 0 { + return mergeAccountMap(current, updates) + } + return MergePreservingSensitiveCreds(current, mergeAccountMap(current, updates)) +} + func accountDuplicateIdentityKeys(account *Account) []ownedAccountDuplicateKey { if account == nil { return nil diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index c74e909b4..ea0555e32 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2486,7 +2486,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Notes = normalizeAccountNotes(input.Notes) } if len(input.Credentials) > 0 { - account.Credentials = input.Credentials + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) } // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 @@ -2699,7 +2699,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if account == nil { continue } - credentials := mergeAccountMap(account.Credentials, input.Credentials) + credentials := mergeAccountMapPreservingSensitiveCreds(account.Credentials, input.Credentials) extra := mergeAccountMap(account.Extra, input.Extra) level := NormalizeOpenAIAccountLevel(account.Platform, account.AccountLevel, credentials, extra) if input.AccountLevel != nil { @@ -2772,7 +2772,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if account == nil { continue } - credentials := mergeAccountMap(account.Credentials, input.Credentials) + credentials := mergeAccountMapPreservingSensitiveCreds(account.Credentials, input.Credentials) extra := mergeAccountMap(account.Extra, input.Extra) level := NormalizeOpenAIAccountLevel(account.Platform, account.AccountLevel, credentials, extra) if input.AccountLevel != nil && account.Platform != PlatformOpenAI { diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 65f4a1d1f..c5145c0f3 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log" @@ -119,6 +120,10 @@ type CostBreakdown struct { BillingMode string // 计费模式("token"/"per_request"/"image"),由 CalculateCostUnified 填充 } +// ErrModelPricingUnavailable indicates that none of the configured pricing +// sources can price the requested model. +var ErrModelPricingUnavailable = errors.New("pricing not found") + // BillingService 计费服务 type BillingService struct { cfg *config.Config @@ -377,7 +382,7 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { return s.applyModelSpecificPricingPolicy(model, fallback), nil } - return nil, fmt.Errorf("pricing not found for model: %s", model) + return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model) } // GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值 @@ -474,7 +479,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos pricing := input.Resolver.GetIntervalPricing(resolved, totalContext) if pricing == nil { - return nil, fmt.Errorf("no pricing available for model: %s", input.Model) + return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable) } pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 92cbd6241..60991f565 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -387,6 +387,8 @@ const ( SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" // SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false) SettingKeyEnableCCHSigning = "enable_cch_signing" + // SettingKeyOpenAICleanRelayEnabled 是否启用 OpenAI 洁净中继模式(默认 false) + SettingKeyOpenAICleanRelayEnabled = "openai_clean_relay_enabled" // SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection" diff --git a/backend/internal/service/error_passthrough_runtime_test.go b/backend/internal/service/error_passthrough_runtime_test.go index 7032d15b9..8cab1631d 100644 --- a/backend/internal/service/error_passthrough_runtime_test.go +++ b/backend/internal/service/error_passthrough_runtime_test.go @@ -76,7 +76,7 @@ func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) { } account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil) + _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil, "") require.Error(t, err) assert.Equal(t, http.StatusBadGateway, rec.Code) @@ -157,7 +157,7 @@ func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) { } account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil) + _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil, "") require.Error(t, err) assert.Equal(t, http.StatusTeapot, rec.Code) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index c27bec778..7bcebcb66 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -145,7 +145,7 @@ func (s *stickyGatewayCacheHotpathStub) DeleteSessionAccountID(ctx context.Conte } func (s *stickyGatewayCacheHotpathStub) GetSessionString(ctx context.Context, groupID int64, sessionHash string) (string, error) { - return "", errors.New("not found") + return "", ErrGatewaySessionStringNotFound } func (s *stickyGatewayCacheHotpathStub) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 81e096b57..c6ca4a7c5 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -242,7 +242,7 @@ func (m *mockGatewayCacheForPlatform) GetSessionString(ctx context.Context, grou return value, nil } } - return "", errors.New("not found") + return "", ErrGatewaySessionStringNotFound } func (m *mockGatewayCacheForPlatform) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 44aa2dd9e..85c6be735 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -407,6 +407,8 @@ type GatewayCache interface { DeleteSessionString(ctx context.Context, groupID int64, sessionHash string) error } +var ErrGatewaySessionStringNotFound = errors.New("gateway session string not found") + // derefGroupID safely dereferences *int64 to int64, returning 0 if nil func derefGroupID(groupID *int64) int64 { if groupID == nil { diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 5cb72db20..c769fe937 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -295,7 +295,7 @@ func (m *mockGatewayCacheForGemini) GetSessionString(ctx context.Context, groupI return value, nil } } - return "", errors.New("not found") + return "", ErrGatewaySessionStringNotFound } func (m *mockGatewayCacheForGemini) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 2a585e58a..a83f4f7a3 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -18,6 +18,7 @@ import ( const ( openAIAccountScheduleLayerPreviousResponse = "previous_response_id" + openAIAccountScheduleLayerCleanRelay = "clean_relay" openAIAccountScheduleLayerSessionSticky = "session_hash" openAIAccountScheduleLayerLoadBalance = "load_balance" openAIAdvancedSchedulerSettingKey = "openai_advanced_scheduler_enabled" @@ -358,7 +359,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) return nil, nil } - account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.RequestedModel, req.RequireCompact) + account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, req.GroupID, account, req.RequestedModel, req.RequireCompact) if account == nil || !s.isAccountTransportCompatible(account, req.RequiredTransport) { _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) return nil, nil @@ -1072,7 +1073,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { continue } - fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false) + fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, req.GroupID, fresh, req.RequestedModel, false) if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { continue } @@ -1103,7 +1104,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { continue } - fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false) + fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, req.GroupID, fresh, req.RequestedModel, false) if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { continue } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 9e0ce9063..e477a7af1 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -36,9 +36,16 @@ func (r schedulerTestOpenAIAccountRepo) GetByID(ctx context.Context, id int64) ( func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) { var result []Account for _, acc := range r.accounts { - if acc.Platform == platform { - result = append(result, acc) + if acc.Platform != platform { + continue + } + if openAITestAccountHasGroupMetadata(acc) { + if openAITestAccountBelongsToGroup(acc, groupID) { + result = append(result, acc) + } + continue } + result = append(result, openAITestAccountWithGroupIfUnset(acc, groupID)) } return result, nil } @@ -156,7 +163,7 @@ func (c *schedulerTestGatewayCache) GetSessionString(ctx context.Context, groupI return value, nil } } - return "", errors.New("not found") + return "", ErrGatewaySessionStringNotFound } func (c *schedulerTestGatewayCache) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { @@ -446,6 +453,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPrev Priority: 0, }, } + accounts = openAITestAccountsWithGroupIfUnset(accounts, groupID) cfg := &config.Config{} cfg.Gateway.Scheduling.LoadBatchEnabled = false cfg.Gateway.OpenAIWS.Enabled = true @@ -503,6 +511,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimite staleBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} freshSticky := &Account{ID: 31001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil} freshBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} + staleSticky = openAITestAccountPtrWithGroupIfUnset(staleSticky, groupID) + staleBackup = openAITestAccountPtrWithGroupIfUnset(staleBackup, groupID) + freshSticky = openAITestAccountPtrWithGroupIfUnset(freshSticky, groupID) + freshBackup = openAITestAccountPtrWithGroupIfUnset(freshBackup, groupID) cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_rate_limited": 31001}} snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{staleSticky, staleBackup}, accountsByID: map[int64]*Account{31001: freshSticky, 31002: freshBackup}} snapshotService := &SchedulerSnapshotService{cache: snapshotCache} @@ -531,6 +543,10 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_SkipsFreshlyRa staleSecondary := &Account{ID: 32002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} freshPrimary := &Account{ID: 32001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil} freshSecondary := &Account{ID: 32002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} + stalePrimary = openAITestAccountPtrWithGroupIfUnset(stalePrimary, groupID) + staleSecondary = openAITestAccountPtrWithGroupIfUnset(staleSecondary, groupID) + freshPrimary = openAITestAccountPtrWithGroupIfUnset(freshPrimary, groupID) + freshSecondary = openAITestAccountPtrWithGroupIfUnset(freshSecondary, groupID) snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{stalePrimary, staleSecondary}, accountsByID: map[int64]*Account{32001: freshPrimary, 32002: freshSecondary}} snapshotService := &SchedulerSnapshotService{cache: snapshotCache} svc := &OpenAIGatewayService{ @@ -554,6 +570,10 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyDBRuntimeR staleBackup := &Account{ID: 33002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} dbSticky := Account{ID: 33001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil} dbBackup := Account{ID: 33002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} + staleSticky = openAITestAccountPtrWithGroupIfUnset(staleSticky, groupID) + staleBackup = openAITestAccountPtrWithGroupIfUnset(staleBackup, groupID) + dbSticky = openAITestAccountWithGroupIfUnset(dbSticky, groupID) + dbBackup = openAITestAccountWithGroupIfUnset(dbBackup, groupID) cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_db_runtime_recheck": 33001}} snapshotCache := &openAISnapshotCacheStub{ snapshotAccounts: []*Account{staleSticky, staleBackup}, @@ -577,6 +597,73 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyDBRuntimeR require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyClearsAccountMovedOutOfGroup(t *testing.T) { + ctx := context.Background() + freeGroupID := int64(1197) + plusGroupID := int64(18) + sessionHash := "session_hash_moved_out_of_group" + + movedOut := Account{ + ID: 234206, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{plusGroupID}, + AccountGroups: []AccountGroup{{AccountID: 234206, GroupID: plusGroupID}}, + } + freeBackup := Account{ + ID: 234207, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + GroupIDs: []int64{freeGroupID}, + AccountGroups: []AccountGroup{{AccountID: 234207, GroupID: freeGroupID}}, + } + cache := &schedulerTestGatewayCache{ + sessionBindings: map[string]int64{"openai:" + sessionHash: movedOut.ID}, + } + snapshotCache := &openAISnapshotCacheStub{ + snapshotAccounts: []*Account{&movedOut, &freeBackup}, + accountsByID: map[int64]*Account{movedOut.ID: &movedOut, freeBackup.ID: &freeBackup}, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{movedOut, freeBackup}}, + cache: cache, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &freeGroupID, + "", + sessionHash, + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, freeBackup.ID, selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickySessionHit) + require.Equal(t, 1, cache.deletedSessions["openai:"+sessionHash]) + require.Equal(t, freeBackup.ID, cache.sessionBindings["openai:"+sessionHash]) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_DBRuntimeRecheckSkipsStaleCachedCandidate(t *testing.T) { ctx := context.Background() groupID := int64(10104) @@ -585,6 +672,10 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_DBRuntimeReche staleSecondary := &Account{ID: 34002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} dbPrimary := Account{ID: 34001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil} dbSecondary := Account{ID: 34002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5} + stalePrimary = openAITestAccountPtrWithGroupIfUnset(stalePrimary, groupID) + staleSecondary = openAITestAccountPtrWithGroupIfUnset(staleSecondary, groupID) + dbPrimary = openAITestAccountWithGroupIfUnset(dbPrimary, groupID) + dbSecondary = openAITestAccountWithGroupIfUnset(dbSecondary, groupID) snapshotCache := &openAISnapshotCacheStub{ snapshotAccounts: []*Account{stalePrimary, staleSecondary}, accountsByID: map[int64]*Account{34001: stalePrimary, 34002: staleSecondary}, @@ -617,6 +708,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky( "openai_apikey_responses_websockets_v2_enabled": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &schedulerTestGatewayCache{} cfg := &config.Config{} cfg.Gateway.OpenAIWS.Enabled = true @@ -670,6 +762,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky(t *testin Schedulable: true, Concurrency: 1, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &schedulerTestGatewayCache{ sessionBindings: map[string]int64{ "openai:session_hash_abc": account.ID, @@ -854,6 +947,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky_ForceHTTP "openai_ws_force_http": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &schedulerTestGatewayCache{ sessionBindings: map[string]int64{ "openai:session_hash_force_http": account.ID, @@ -1093,6 +1187,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) { Schedulable: true, Concurrency: 1, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &schedulerTestGatewayCache{ sessionBindings: map[string]int64{ "openai:session_hash_metrics": account.ID, diff --git a/backend/internal/service/openai_account_scheduler_ws_snapshot_test.go b/backend/internal/service/openai_account_scheduler_ws_snapshot_test.go index 8d63e68e2..3c4343401 100644 --- a/backend/internal/service/openai_account_scheduler_ws_snapshot_test.go +++ b/backend/internal/service/openai_account_scheduler_ws_snapshot_test.go @@ -24,6 +24,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesWSPassthroughSnapsh "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough, }, } + account = openAITestAccountPtrWithGroupIfUnset(account, groupID) snapshotCache := &openAISnapshotCacheStub{ snapshotAccounts: []*Account{account}, diff --git a/backend/internal/service/openai_clean_relay.go b/backend/internal/service/openai_clean_relay.go new file mode 100644 index 000000000..fabfa1ad3 --- /dev/null +++ b/backend/internal/service/openai_clean_relay.go @@ -0,0 +1,605 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + openAICleanRelayContextKey = "openai_clean_relay_state" + openAICleanRelayGroupContextKey = "openai_clean_relay_group_id" + openAICleanRelayInstallationField = "x-codex-installation-id" + openAICleanRelayCacheKeyPrefix = "openai:clean_relay:" +) + +type openAICleanRelayMapping struct { + AccountID int64 `json:"account_id"` + Epoch int64 `json:"epoch"` + InstallationID string `json:"installation_id"` + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + PromptCacheKey string `json:"prompt_cache_key"` +} + +type openAICleanRelayState struct { + Mapping openAICleanRelayMapping + CleanStart bool + Ephemeral bool + AllowBodyClientMetadata bool + bodyCleaned bool + headersCleaned bool +} + +func (s *OpenAIGatewayService) applyOpenAICleanRelayToRequestBody( + ctx context.Context, + c *gin.Context, + account *Account, + reqBody map[string]any, + bodyForSession []byte, +) (*openAICleanRelayState, bool, error) { + if len(reqBody) == 0 { + return nil, false, nil + } + if existing := getOpenAICleanRelayState(c); existing != nil && account != nil && existing.Mapping.AccountID == account.ID { + changed := applyOpenAICleanRelayMappingToBody(reqBody, existing) + return existing, changed, nil + } + state, err := s.resolveOpenAICleanRelayState(ctx, c, account, reqBody, bodyForSession) + if err != nil || state == nil { + return state, false, err + } + changed := applyOpenAICleanRelayMappingToBody(reqBody, state) + setOpenAICleanRelayState(c, state) + return state, changed, nil +} + +func (s *OpenAIGatewayService) applyOpenAICleanRelayToRawBody( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + bodyForSession []byte, +) ([]byte, *openAICleanRelayState, bool, error) { + if len(body) == 0 { + return body, nil, false, nil + } + if !s.isOpenAICleanRelayActive(ctx, account) { + return body, nil, false, nil + } + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return body, nil, false, fmt.Errorf("openai clean relay parse request body: %w", err) + } + if len(bodyForSession) == 0 { + bodyForSession = body + } + state, changed, err := s.applyOpenAICleanRelayToRequestBody(ctx, c, account, reqBody, bodyForSession) + if err != nil || state == nil || !changed { + return body, state, changed, err + } + rebuilt, err := json.Marshal(reqBody) + if err != nil { + return body, state, false, fmt.Errorf("openai clean relay serialize request body: %w", err) + } + return rebuilt, state, true, nil +} + +func (s *OpenAIGatewayService) SelectAccountWithCleanRelayScheduler( + ctx context.Context, + c *gin.Context, + groupID *int64, + previousResponseID string, + sessionHash string, + requestedModel string, + routingModel string, + excludedIDs map[int64]struct{}, + requiredTransport OpenAIUpstreamTransport, + requireCompact bool, + bodyForSession []byte, +) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + setOpenAICleanRelayGroupID(c, groupID) + effectiveModel := strings.TrimSpace(routingModel) + if effectiveModel == "" { + effectiveModel = strings.TrimSpace(requestedModel) + } + selection, decision, hit, err := s.selectOpenAICleanRelayMappedAccount( + ctx, + c, + groupID, + effectiveModel, + excludedIDs, + requiredTransport, + requireCompact, + bodyForSession, + ) + if err != nil { + return nil, decision, err + } + if hit { + return selection, decision, nil + } + return s.SelectAccountWithScheduler( + ctx, + groupID, + previousResponseID, + sessionHash, + effectiveModel, + excludedIDs, + requiredTransport, + requireCompact, + ) +} + +func (s *OpenAIGatewayService) selectOpenAICleanRelayMappedAccount( + ctx context.Context, + c *gin.Context, + groupID *int64, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredTransport OpenAIUpstreamTransport, + requireCompact bool, + bodyForSession []byte, +) (*AccountSelectionResult, OpenAIAccountScheduleDecision, bool, error) { + decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerCleanRelay} + mapping, hit, err := s.loadOpenAICleanRelayCachedMapping(ctx, c, bodyForSession) + if err != nil || !hit { + return nil, decision, false, err + } + if mapping.AccountID <= 0 { + return nil, decision, false, nil + } + if excludedIDs != nil { + if _, excluded := excludedIDs[mapping.AccountID]; excluded { + return nil, decision, false, nil + } + } + selection, err := s.selectOpenAICleanRelayAccountByID( + ctx, + groupID, + mapping.AccountID, + requestedModel, + requiredTransport, + requireCompact, + ) + if err != nil { + return nil, decision, true, err + } + if selection == nil || selection.Account == nil { + return nil, decision, false, nil + } + decision.StickySessionHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, true, nil +} + +func (s *OpenAIGatewayService) loadOpenAICleanRelayCachedMapping( + ctx context.Context, + c *gin.Context, + bodyForSession []byte, +) (openAICleanRelayMapping, bool, error) { + if !s.IsOpenAICleanRelayEnabled(ctx) || c == nil || len(bodyForSession) == 0 || s.cache == nil { + return openAICleanRelayMapping{}, false, nil + } + var reqBody map[string]any + if err := json.Unmarshal(bodyForSession, &reqBody); err != nil { + return openAICleanRelayMapping{}, false, fmt.Errorf("openai clean relay parse request body before account selection: %w", err) + } + clientInstallationID := openAICleanRelayClientInstallationID(c, reqBody) + sessionSignal := openAICleanRelayClientSessionSignal(c, reqBody, bodyForSession) + if strings.TrimSpace(sessionSignal) == "" { + return openAICleanRelayMapping{}, false, nil + } + apiKeyID := getAPIKeyIDFromContext(c) + groupID := getOpenAICleanRelayGroupID(c) + cacheKey := openAICleanRelayCacheKey(apiKeyID, groupID, clientInstallationID, sessionSignal) + cacheCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + + raw, err := s.cache.GetSessionString(cacheCtx, groupID, cacheKey) + if err != nil { + if errors.Is(err, ErrGatewaySessionStringNotFound) { + return openAICleanRelayMapping{}, false, nil + } + return openAICleanRelayMapping{}, false, fmt.Errorf("openai clean relay load mapping before account selection: %w", err) + } + if strings.TrimSpace(raw) == "" { + return openAICleanRelayMapping{}, false, nil + } + var mapping openAICleanRelayMapping + if err := json.Unmarshal([]byte(raw), &mapping); err != nil { + return openAICleanRelayMapping{}, false, fmt.Errorf("openai clean relay decode mapping before account selection: %w", err) + } + if mapping.AccountID <= 0 || mapping.InstallationID == "" || mapping.SessionID == "" || mapping.ConversationID == "" || mapping.PromptCacheKey == "" { + return openAICleanRelayMapping{}, false, errors.New("openai clean relay mapping is incomplete before account selection") + } + return mapping, true, nil +} + +func (s *OpenAIGatewayService) selectOpenAICleanRelayAccountByID( + ctx context.Context, + groupID *int64, + accountID int64, + requestedModel string, + requiredTransport OpenAIUpstreamTransport, + requireCompact bool, +) (*AccountSelectionResult, error) { + account, err := s.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + return nil, nil + } + if !s.isOpenAICleanRelayAccountCandidate(ctx, account) { + return nil, nil + } + if !s.isOpenAIAccountTransportCompatible(account, requiredTransport) { + return nil, nil + } + account = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, account, requestedModel, requireCompact) + if account == nil || !s.isOpenAICleanRelayAccountCandidate(ctx, account) { + return nil, nil + } + if !s.isOpenAIAccountTransportCompatible(account, requiredTransport) { + return nil, nil + } + if groupID != nil && s.needsUpstreamChannelRestrictionCheck(ctx, groupID) && + s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { + return nil, nil + } + result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) + if err != nil { + return nil, err + } + if result != nil && result.Acquired { + return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) + } + cfg := s.schedulingConfig() + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) +} + +func (s *OpenAIGatewayService) isOpenAICleanRelayAccountCandidate(ctx context.Context, account *Account) bool { + return s.isOpenAICleanRelayActive(ctx, account) && account.IsOpenAI() && account.IsSchedulable() +} + +func (s *OpenAIGatewayService) resolveOpenAICleanRelayState( + ctx context.Context, + c *gin.Context, + account *Account, + reqBody map[string]any, + bodyForSession []byte, +) (*openAICleanRelayState, error) { + if !s.isOpenAICleanRelayActive(ctx, account) { + return nil, nil + } + + accountID := account.ID + upstreamInstallationID := openAICleanRelayInstallationID(accountID) + clientInstallationID := openAICleanRelayClientInstallationID(c, reqBody) + sessionSignal := openAICleanRelayClientSessionSignal(c, reqBody, bodyForSession) + allowBodyClientMetadata := !isOpenAICleanRelayCompactRequest(c) + if sessionSignal == "" { + return &openAICleanRelayState{ + Mapping: newOpenAICleanRelayMapping(accountID, 1, upstreamInstallationID), + CleanStart: true, + Ephemeral: true, + AllowBodyClientMetadata: allowBodyClientMetadata, + }, nil + } + + if s.cache == nil { + return nil, errors.New("openai clean relay cache is unavailable") + } + + apiKeyID := getAPIKeyIDFromContext(c) + groupID := getOpenAICleanRelayGroupID(c) + cacheKey := openAICleanRelayCacheKey(apiKeyID, groupID, clientInstallationID, sessionSignal) + cacheCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + + raw, err := s.cache.GetSessionString(cacheCtx, groupID, cacheKey) + if err != nil && !errors.Is(err, ErrGatewaySessionStringNotFound) { + return nil, fmt.Errorf("openai clean relay load mapping: %w", err) + } + if errors.Is(err, ErrGatewaySessionStringNotFound) || strings.TrimSpace(raw) == "" { + mapping := newOpenAICleanRelayMapping(accountID, 1, upstreamInstallationID) + encoded, encodeErr := marshalOpenAICleanRelayMapping(mapping) + if encodeErr != nil { + return nil, encodeErr + } + if err := s.cache.SetSessionString(cacheCtx, groupID, cacheKey, encoded, s.openAIWSSessionStickyTTL()); err != nil { + return nil, fmt.Errorf("openai clean relay save mapping: %w", err) + } + return &openAICleanRelayState{Mapping: mapping, CleanStart: true, AllowBodyClientMetadata: allowBodyClientMetadata}, nil + } + + var mapping openAICleanRelayMapping + if err := json.Unmarshal([]byte(raw), &mapping); err != nil { + return nil, fmt.Errorf("openai clean relay decode mapping: %w", err) + } + if mapping.AccountID <= 0 || mapping.InstallationID == "" || mapping.SessionID == "" || mapping.ConversationID == "" || mapping.PromptCacheKey == "" { + return nil, errors.New("openai clean relay mapping is incomplete") + } + + if mapping.AccountID == accountID { + encoded, encodeErr := marshalOpenAICleanRelayMapping(mapping) + if encodeErr != nil { + return nil, encodeErr + } + if err := s.cache.SetSessionString(cacheCtx, groupID, cacheKey, encoded, s.openAIWSSessionStickyTTL()); err != nil { + return nil, fmt.Errorf("openai clean relay refresh mapping: %w", err) + } + return &openAICleanRelayState{Mapping: mapping, AllowBodyClientMetadata: allowBodyClientMetadata}, nil + } + + nextEpoch := mapping.Epoch + 1 + if nextEpoch <= 0 { + nextEpoch = 1 + } + mapping = newOpenAICleanRelayMapping(accountID, nextEpoch, upstreamInstallationID) + encoded, encodeErr := marshalOpenAICleanRelayMapping(mapping) + if encodeErr != nil { + return nil, encodeErr + } + if err := s.cache.SetSessionString(cacheCtx, groupID, cacheKey, encoded, s.openAIWSSessionStickyTTL()); err != nil { + return nil, fmt.Errorf("openai clean relay migrate mapping: %w", err) + } + return &openAICleanRelayState{Mapping: mapping, CleanStart: true, AllowBodyClientMetadata: allowBodyClientMetadata}, nil +} + +func (s *OpenAIGatewayService) isOpenAICleanRelayActive(ctx context.Context, account *Account) bool { + if s == nil || s.settingService == nil || account == nil { + return false + } + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return false + } + return s.IsOpenAICleanRelayEnabled(ctx) +} + +// IsOpenAICleanRelayEnabled reports whether the gateway-level clean relay mode +// is enabled, independent of any account-specific applicability checks. +func (s *OpenAIGatewayService) IsOpenAICleanRelayEnabled(ctx context.Context) bool { + if s == nil || s.settingService == nil { + return false + } + return s.settingService.IsOpenAICleanRelayEnabled(ctx) +} + +func newOpenAICleanRelayMapping(accountID, epoch int64, installationID string) openAICleanRelayMapping { + sessionID := uuid.NewString() + return openAICleanRelayMapping{ + AccountID: accountID, + Epoch: epoch, + InstallationID: installationID, + SessionID: sessionID, + ConversationID: uuid.NewString(), + PromptCacheKey: "clean_relay:" + sessionID, + } +} + +func openAICleanRelayInstallationID(accountID int64) string { + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("sub2api:openai:clean_relay:account:%d", accountID))).String() +} + +func openAICleanRelayCacheKey(apiKeyID, groupID int64, clientInstallationID, sessionSignal string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf( + "api_key:%d|group:%d|installation:%s|session:%s", + apiKeyID, + groupID, + strings.TrimSpace(clientInstallationID), + strings.TrimSpace(sessionSignal), + ))) + return openAICleanRelayCacheKeyPrefix + hex.EncodeToString(sum[:]) +} + +func marshalOpenAICleanRelayMapping(mapping openAICleanRelayMapping) (string, error) { + data, err := json.Marshal(mapping) + if err != nil { + return "", fmt.Errorf("openai clean relay encode mapping: %w", err) + } + return string(data), nil +} + +func openAICleanRelayClientInstallationID(c *gin.Context, reqBody map[string]any) string { + if c != nil { + if value := strings.TrimSpace(c.GetHeader(openAICleanRelayInstallationField)); value != "" { + return value + } + } + return strings.TrimSpace(openAICleanRelayClientMetadataString(reqBody, openAICleanRelayInstallationField)) +} + +func openAICleanRelayClientSessionSignal(c *gin.Context, reqBody map[string]any, bodyForSession []byte) string { + if signal := strings.TrimSpace(explicitOpenAISessionID(c, bodyForSession)); signal != "" { + return signal + } + if signal := strings.TrimSpace(openAICleanRelayBodyString(reqBody, "prompt_cache_key")); signal != "" { + return signal + } + return "" +} + +func applyOpenAICleanRelayMappingToBody(reqBody map[string]any, state *openAICleanRelayState) bool { + if len(reqBody) == 0 || state == nil { + return false + } + changed := false + mapping := state.Mapping + if strings.TrimSpace(mapping.PromptCacheKey) != "" && openAICleanRelayBodyString(reqBody, "prompt_cache_key") != mapping.PromptCacheKey { + reqBody["prompt_cache_key"] = mapping.PromptCacheKey + changed = true + } + if state.AllowBodyClientMetadata { + if setOpenAICleanRelayClientMetadata(reqBody, mapping.InstallationID) { + changed = true + } + } else if _, exists := reqBody["client_metadata"]; exists { + delete(reqBody, "client_metadata") + changed = true + } + if state.CleanStart && !state.bodyCleaned { + if _, ok := reqBody["previous_response_id"]; ok { + delete(reqBody, "previous_response_id") + changed = true + } + if trimOpenAIEncryptedReasoningItems(reqBody) { + changed = true + } + state.bodyCleaned = true + } + return changed +} + +func (s *OpenAIGatewayService) applyOpenAICleanRelayHeaders(c *gin.Context, req *http.Request) { + state := getOpenAICleanRelayState(c) + if state == nil || req == nil { + return + } + mapping := state.Mapping + req.Header.Set(openAICleanRelayInstallationField, mapping.InstallationID) + req.Header.Set("session_id", mapping.SessionID) + req.Header.Set("conversation_id", mapping.ConversationID) + if state.CleanStart && !state.headersCleaned { + req.Header.Del(openAIWSTurnStateHeader) + state.headersCleaned = true + } +} + +func applyOpenAICleanRelayWSHeaders(c *gin.Context, headers http.Header) { + state := getOpenAICleanRelayState(c) + if state == nil || headers == nil { + return + } + mapping := state.Mapping + headers.Set(openAICleanRelayInstallationField, mapping.InstallationID) + headers.Set("session_id", mapping.SessionID) + headers.Set("conversation_id", mapping.ConversationID) + if state.CleanStart && !state.headersCleaned { + headers.Del(openAIWSTurnStateHeader) + state.headersCleaned = true + } +} + +func setOpenAICleanRelayState(c *gin.Context, state *openAICleanRelayState) { + if c != nil && state != nil { + c.Set(openAICleanRelayContextKey, state) + } +} + +func setOpenAICleanRelayGroupID(c *gin.Context, groupID *int64) { + if c != nil && groupID != nil && *groupID > 0 { + c.Set(openAICleanRelayGroupContextKey, *groupID) + } +} + +func isOpenAICleanRelayCompactRequest(c *gin.Context) bool { + if c == nil || c.Request == nil { + return false + } + path := strings.TrimRight(strings.ToLower(strings.TrimSpace(c.Request.URL.Path)), "/") + return strings.HasSuffix(path, "/responses/compact") +} + +func getOpenAICleanRelayGroupID(c *gin.Context) int64 { + if c == nil { + return 0 + } + if value, exists := c.Get(openAICleanRelayGroupContextKey); exists { + if groupID, ok := value.(int64); ok && groupID > 0 { + return groupID + } + } + return getOpenAIGroupIDFromContext(c) +} + +func getOpenAICleanRelayState(c *gin.Context) *openAICleanRelayState { + if c == nil { + return nil + } + value, exists := c.Get(openAICleanRelayContextKey) + if !exists { + return nil + } + state, _ := value.(*openAICleanRelayState) + return state +} + +func openAICleanRelayBodyString(reqBody map[string]any, key string) string { + if len(reqBody) == 0 { + return "" + } + value, ok := reqBody[key] + if !ok { + return "" + } + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case []byte: + return strings.TrimSpace(string(v)) + default: + return "" + } +} + +func openAICleanRelayClientMetadataString(reqBody map[string]any, key string) string { + if len(reqBody) == 0 { + return "" + } + switch metadata := reqBody["client_metadata"].(type) { + case map[string]any: + if value, ok := metadata[key]; ok { + if s, ok := value.(string); ok { + return strings.TrimSpace(s) + } + } + case map[string]string: + return strings.TrimSpace(metadata[key]) + } + return "" +} + +func setOpenAICleanRelayClientMetadata(reqBody map[string]any, installationID string) bool { + installationID = strings.TrimSpace(installationID) + if len(reqBody) == 0 || installationID == "" { + return false + } + switch metadata := reqBody["client_metadata"].(type) { + case map[string]any: + if existing, _ := metadata[openAICleanRelayInstallationField].(string); strings.TrimSpace(existing) == installationID { + return false + } + metadata[openAICleanRelayInstallationField] = installationID + return true + case map[string]string: + if strings.TrimSpace(metadata[openAICleanRelayInstallationField]) == installationID { + return false + } + next := make(map[string]any, len(metadata)+1) + for k, v := range metadata { + next[k] = v + } + next[openAICleanRelayInstallationField] = installationID + reqBody["client_metadata"] = next + return true + default: + reqBody["client_metadata"] = map[string]any{ + openAICleanRelayInstallationField: installationID, + } + return true + } +} diff --git a/backend/internal/service/openai_clean_relay_test.go b/backend/internal/service/openai_clean_relay_test.go new file mode 100644 index 000000000..80305ae18 --- /dev/null +++ b/backend/internal/service/openai_clean_relay_test.go @@ -0,0 +1,361 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type cleanRelayErrorGatewayCache struct { + stubGatewayCache + getErr error +} + +type cleanRelaySettingRepoStub struct { + values map[string]string +} + +func (s *cleanRelaySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + value, err := s.GetValue(ctx, key) + if err != nil { + return nil, err + } + return &Setting{Key: key, Value: value}, nil +} + +func (s *cleanRelaySettingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + _ = ctx + if s != nil && s.values != nil { + if value, ok := s.values[key]; ok { + return value, nil + } + } + return "", ErrSettingNotFound +} + +func (s *cleanRelaySettingRepoStub) Set(context.Context, string, string) error { + panic("unexpected Set call") +} + +func (s *cleanRelaySettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + _ = ctx + result := make(map[string]string, len(keys)) + for _, key := range keys { + if s != nil && s.values != nil { + if value, ok := s.values[key]; ok { + result[key] = value + } + } + } + return result, nil +} + +func (s *cleanRelaySettingRepoStub) SetMultiple(context.Context, map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *cleanRelaySettingRepoStub) GetAll(context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *cleanRelaySettingRepoStub) Delete(context.Context, string) error { + panic("unexpected Delete call") +} + +func (c *cleanRelayErrorGatewayCache) GetSessionString(ctx context.Context, groupID int64, sessionHash string) (string, error) { + if c.getErr != nil { + return "", c.getErr + } + return c.stubGatewayCache.GetSessionString(ctx, groupID, sessionHash) +} + +func newCleanRelaySettingService(enabled bool) *SettingService { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{}) + value := "false" + if enabled { + value = "true" + } + return NewSettingService(&cleanRelaySettingRepoStub{ + values: map[string]string{ + SettingKeyOpenAICleanRelayEnabled: value, + }, + }, &config.Config{}) +} + +func newCleanRelayGinContext(apiKeyID int64, groupID int64) *gin.Context { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + req.Header.Set(openAICleanRelayInstallationField, "client-installation") + req.Header.Set("session_id", "client-session") + req.Header.Set("conversation_id", "client-conversation") + req.Header.Set(openAIWSTurnStateHeader, "client-turn-state") + c.Request = req + c.Set("api_key", &APIKey{ID: apiKeyID, GroupID: &groupID}) + return c +} + +func newCleanRelayOAuthAccount(id int64) *Account { + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } +} + +func TestOpenAICleanRelay_FirstCleanStartRewritesBodyAndHeaders(t *testing.T) { + ctx := context.Background() + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{ + cache: cache, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, 202) + account := newCleanRelayOAuthAccount(303) + body := []byte(`{"model":"gpt-5.1","prompt_cache_key":"client-cache","previous_response_id":"resp_old","client_metadata":{"x-codex-installation-id":"client-body-installation"},"input":[{"type":"reasoning","encrypted_content":"sealed"},{"type":"input_text","text":"hello"}]}`) + + rewritten, state, changed, err := svc.applyOpenAICleanRelayToRawBody(ctx, c, account, body, body) + require.NoError(t, err) + require.True(t, changed) + require.NotNil(t, state) + require.True(t, state.CleanStart) + require.True(t, state.bodyCleaned) + require.False(t, state.headersCleaned) + require.False(t, gjson.GetBytes(rewritten, "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "input.0.encrypted_content").Exists()) + require.Equal(t, "input_text", gjson.GetBytes(rewritten, "input.0.type").String()) + require.Equal(t, state.Mapping.PromptCacheKey, gjson.GetBytes(rewritten, "prompt_cache_key").String()) + require.Equal(t, state.Mapping.InstallationID, gjson.GetBytes(rewritten, "client_metadata.x-codex-installation-id").String()) + require.Len(t, cache.stringBindings, 1) + + headers := http.Header{} + headers.Set(openAIWSTurnStateHeader, "client-turn-state") + applyOpenAICleanRelayWSHeaders(c, headers) + require.Equal(t, state.Mapping.InstallationID, headers.Get(openAICleanRelayInstallationField)) + require.Equal(t, state.Mapping.SessionID, headers.Get("session_id")) + require.Equal(t, state.Mapping.ConversationID, headers.Get("conversation_id")) + require.Empty(t, headers.Get(openAIWSTurnStateHeader)) + require.True(t, state.headersCleaned) +} + +func TestOpenAICleanRelay_CacheReadErrorFailsFast(t *testing.T) { + ctx := context.Background() + cacheErr := errors.New("redis unavailable") + svc := &OpenAIGatewayService{ + cache: &cleanRelayErrorGatewayCache{getErr: cacheErr}, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, 202) + body := []byte(`{"model":"gpt-5.1","prompt_cache_key":"client-cache","previous_response_id":"resp_old"}`) + rewritten, state, changed, err := svc.applyOpenAICleanRelayToRawBody(ctx, c, newCleanRelayOAuthAccount(303), body, body) + + require.Error(t, err) + require.ErrorIs(t, err, cacheErr) + require.Nil(t, state) + require.False(t, changed) + require.JSONEq(t, string(body), string(rewritten)) +} + +func TestOpenAICleanRelay_CompactDoesNotInjectBodyClientMetadata(t *testing.T) { + ctx := context.Background() + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{ + cache: cache, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, 202) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil) + c.Request.Header.Set(openAICleanRelayInstallationField, "client-installation") + c.Request.Header.Set("session_id", "client-session") + body := []byte(`{"model":"gpt-5.1","prompt_cache_key":"client-cache","client_metadata":{"x-codex-installation-id":"client-body-installation"},"input":[{"type":"input_text","text":"hello"}]}`) + + rewritten, state, changed, err := svc.applyOpenAICleanRelayToRawBody(ctx, c, newCleanRelayOAuthAccount(303), body, body) + + require.NoError(t, err) + require.True(t, changed) + require.NotNil(t, state) + require.False(t, state.AllowBodyClientMetadata) + require.False(t, gjson.GetBytes(rewritten, "client_metadata").Exists()) + require.Equal(t, state.Mapping.PromptCacheKey, gjson.GetBytes(rewritten, "prompt_cache_key").String()) +} + +func TestOpenAICleanRelay_PreselectsCachedAccountBeforeScheduler(t *testing.T) { + ctx := context.Background() + groupID := int64(202) + cachedAccount := openAITestAccountWithGroupIfUnset(*newCleanRelayOAuthAccount(303), groupID) + otherAccount := openAITestAccountWithGroupIfUnset(*newCleanRelayOAuthAccount(404), groupID) + otherAccount.Priority = -10 + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{cachedAccount, otherAccount}}, + cache: cache, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, groupID) + body := []byte(`{"model":"codex-auto-review","prompt_cache_key":"client-cache","previous_response_id":"resp_old"}`) + _, state, changed, err := svc.applyOpenAICleanRelayToRawBody(ctx, c, &cachedAccount, body, body) + require.NoError(t, err) + require.True(t, changed) + require.NotNil(t, state) + + c = newCleanRelayGinContext(101, groupID) + selection, decision, err := svc.SelectAccountWithCleanRelayScheduler( + ctx, + c, + &groupID, + "resp_old", + "", + "codex-auto-review", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + body, + ) + + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, cachedAccount.ID, selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerCleanRelay, decision.Layer) + require.True(t, decision.StickySessionHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAICleanRelay_PreselectFallsBackWhenCachedAccountUnavailable(t *testing.T) { + ctx := context.Background() + groupID := int64(202) + unavailableAccount := openAITestAccountWithGroupIfUnset(*newCleanRelayOAuthAccount(303), groupID) + availableAccount := openAITestAccountWithGroupIfUnset(*newCleanRelayOAuthAccount(404), groupID) + unavailableAccount.Status = StatusDisabled + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{unavailableAccount, availableAccount}}, + cache: cache, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, groupID) + body := []byte(`{"model":"codex-auto-review","prompt_cache_key":"client-cache"}`) + mapping := newOpenAICleanRelayMapping(unavailableAccount.ID, 1, openAICleanRelayInstallationID(unavailableAccount.ID)) + encoded, err := marshalOpenAICleanRelayMapping(mapping) + require.NoError(t, err) + cacheKey := openAICleanRelayCacheKey(101, groupID, "client-installation", "client-cache") + require.NoError(t, cache.SetSessionString(ctx, groupID, cacheKey, encoded, time.Hour)) + + selection, decision, err := svc.SelectAccountWithCleanRelayScheduler( + ctx, + c, + &groupID, + "", + "", + "codex-auto-review", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + body, + ) + + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, availableAccount.ID, selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAICleanRelay_PreselectUsesCurrentRouteGroupForCacheKey(t *testing.T) { + ctx := context.Background() + originalGroupID := int64(59) + routeGroupID := int64(202) + account := openAITestAccountWithGroupIfUnset(*newCleanRelayOAuthAccount(303), routeGroupID) + cache := &stubGatewayCache{} + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + cache: cache, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + c := newCleanRelayGinContext(101, originalGroupID) + body := []byte(`{"model":"codex-auto-review","prompt_cache_key":"client-cache"}`) + mapping := newOpenAICleanRelayMapping(account.ID, 1, openAICleanRelayInstallationID(account.ID)) + encoded, err := marshalOpenAICleanRelayMapping(mapping) + require.NoError(t, err) + routeCacheKey := openAICleanRelayCacheKey(101, routeGroupID, "client-installation", "client-session") + originalCacheKey := openAICleanRelayCacheKey(101, originalGroupID, "client-installation", "client-session") + require.NoError(t, cache.SetSessionString(ctx, routeGroupID, routeCacheKey, encoded, time.Hour)) + require.NoError(t, cache.SetSessionString(ctx, originalGroupID, originalCacheKey, `{"account_id":999}`, time.Hour)) + + selection, decision, err := svc.SelectAccountWithCleanRelayScheduler( + ctx, + c, + &routeGroupID, + "", + "", + "codex-auto-review", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + body, + ) + + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, account.ID, selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerCleanRelay, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + + _, state, changed, err := svc.applyOpenAICleanRelayToRawBody(ctx, c, selection.Account, body, body) + require.NoError(t, err) + require.NotNil(t, state) + require.True(t, changed) + require.Equal(t, account.ID, state.Mapping.AccountID) +} diff --git a/backend/internal/service/openai_gateway_403_reset_test.go b/backend/internal/service/openai_gateway_403_reset_test.go index c68054649..513e01daf 100644 --- a/backend/internal/service/openai_gateway_403_reset_test.go +++ b/backend/internal/service/openai_gateway_403_reset_test.go @@ -34,6 +34,6 @@ func TestOpenAIGatewayServiceRecordUsage_ResetsOpenAI403CounterBeforeZeroUsageRe Account: &Account{ID: 777, Platform: PlatformOpenAI}, }) - require.NoError(t, err) + require.ErrorContains(t, err, "api key is nil") require.Equal(t, []int64{777}, counter.resetCalls) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 7ac1bdb91..baa2dbbcf 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -50,6 +51,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + startTime := time.Now() // 1. Parse Chat Completions request @@ -249,7 +254,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( Detail: upstreamDetail, }) if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, originalModel, resp.StatusCode, resp.Header, respBody) } return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, @@ -257,7 +262,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), } } - return s.handleChatCompletionsErrorResponse(resp, c, account) + return s.handleChatCompletionsErrorResponse(resp, c, account, originalModel) } // 9. Handle normal response @@ -337,8 +342,9 @@ func (s *OpenAIGatewayService) handleChatCompletionsErrorResponse( resp *http.Response, c *gin.Context, account *Account, + requestedModel string, ) (*OpenAIForwardResult, error) { - return s.handleCompatErrorResponse(resp, c, account, writeChatCompletionsError) + return s.handleCompatErrorResponse(resp, c, account, requestedModel, writeChatCompletionsError) } // handleChatBufferedStreamingResponse reads all Responses SSE events from the @@ -367,10 +373,10 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( for scanner.Scan() { line := scanner.Text() - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - payload := line[6:] var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { @@ -578,10 +584,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( if keepaliveInterval <= 0 { for scanner.Scan() { line := scanner.Text() - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - if processDataLine(line[6:]) { + if processDataLine(payload) { return resultWithUsage(), nil } } @@ -633,10 +640,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } lastDataAt = time.Now() line := ev.line - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - if processDataLine(line[6:]) { + if processDataLine(payload) { 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 new file mode 100644 index 000000000..b5a60a51c --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -0,0 +1,338 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.uber.org/zap" +) + +var openaiChatRawAllowedHeaders = map[string]bool{ + "accept-language": true, + "user-agent": true, +} + +func (s *OpenAIGatewayService) forwardAsRawChatCompletions( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + originalModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if originalModel == "" { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, errors.New("missing model in request") + } + clientStream := gjson.GetBytes(body, "stream").Bool() + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) + serviceTier := extractOpenAIServiceTierFromBody(body) + + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + + upstreamBody := body + if upstreamModel != originalModel { + upstreamBody = ReplaceModelInBody(body, upstreamModel) + } + + var err error + upstreamBody, err = s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, upstreamBody) + if err != nil { + var blocked *OpenAIFastBlockedError + if errors.As(err, &blocked) { + writeChatCompletionsError(c, http.StatusForbidden, "permission_error", blocked.Message) + } + return nil, err + } + serviceTier = extractOpenAIServiceTierFromBody(upstreamBody) + if clientStream { + upstreamBody, err = ensureOpenAIChatStreamUsage(upstreamBody) + if err != nil { + return nil, fmt.Errorf("enable stream usage: %w", err) + } + } + + token := account.GetOpenAIApiKey() + if token == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + baseURL := account.GetOpenAIBaseURL() + if baseURL == "" { + baseURL = "https://api.openai.com" + } + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, err + } + targetURL := buildOpenAIChatCompletionsURL(validatedURL) + + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(upstreamBody)) + if err != nil { + return nil, fmt.Errorf("build upstream request: %w", err) + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer "+token) + if clientStream { + upstreamReq.Header.Set("Accept", "text/event-stream") + } else { + upstreamReq.Header.Set("Accept", "application/json") + } + if c != nil && c.Request != nil { + for key, values := range c.Request.Header { + if !openaiChatRawAllowedHeaders[strings.ToLower(key)] { + continue + } + for _, value := range values { + upstreamReq.Header.Add(key, value) + } + } + } + if userAgent := strings.TrimSpace(account.GetOpenAIUserAgent()); userAgent != "" { + upstreamReq.Header.Set("user-agent", userAgent) + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + setOpsUpstreamError(c, 0, safeErr, "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + Kind: "request_error", + Message: safeErr, + }) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") + return nil, fmt.Errorf("upstream request failed: %s", safeErr) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: upstreamMsg, + }) + if s.rateLimitService != nil { + s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, originalModel, resp.StatusCode, resp.Header, respBody) + } + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + } + } + return s.handleChatCompletionsErrorResponse(resp, c, account, originalModel) + } + + if clientStream { + return s.streamRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + } + return s.bufferRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) +} + +func (s *OpenAIGatewayService) streamRawChatCompletions( + c *gin.Context, + resp *http.Response, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + serviceTier *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + var usage OpenAIUsage + var firstTokenMs *int + for scanner.Scan() { + line := scanner.Text() + if payload, ok := extractOpenAISSEDataLine(line); ok && strings.TrimSpace(payload) != "[DONE]" { + usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload) + if u := extractOpenAIChatStreamUsage(payload); u != nil { + usage = *u + } + if firstTokenMs == nil && !usageOnlyChunk { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + } + if _, err := c.Writer.WriteString(line + "\n"); err != nil { + logger.L().Info("openai chat_completions raw: client disconnected", + zap.String("request_id", requestID), + ) + break + } + if line == "" { + c.Writer.Flush() + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai chat_completions raw: stream read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + ReasoningEffort: reasoningEffort, + ServiceTier: serviceTier, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + }, nil +} + +func ensureOpenAIChatStreamUsage(body []byte) ([]byte, error) { + updated, err := sjson.SetBytes(body, "stream_options.include_usage", true) + if err != nil { + return body, err + } + return updated, nil +} + +func isOpenAIChatUsageOnlyStreamChunk(payload string) bool { + if strings.TrimSpace(payload) == "" || !gjson.Get(payload, "usage").Exists() { + return false + } + choices := gjson.Get(payload, "choices") + return choices.Exists() && choices.IsArray() && len(choices.Array()) == 0 +} + +func extractOpenAIChatStreamUsage(payload string) *OpenAIUsage { + usageResult := gjson.Get(payload, "usage") + if !usageResult.Exists() || !usageResult.IsObject() { + return nil + } + return openAIUsageFromChatCompletionsUsage(payload) +} + +func (s *OpenAIGatewayService) bufferRawChatCompletions( + c *gin.Context, + resp *http.Response, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + serviceTier *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { + writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Failed to read upstream response") + } + return nil, fmt.Errorf("read upstream body: %w", err) + } + + var usage OpenAIUsage + if u := openAIUsageFromChatCompletionsUsage(string(respBody)); u != nil { + usage = *u + } + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(http.StatusOK, contentType, respBody) + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + ReasoningEffort: reasoningEffort, + ServiceTier: serviceTier, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +func openAIUsageFromChatCompletionsUsage(payload string) *OpenAIUsage { + if strings.TrimSpace(payload) == "" { + return nil + } + usageResult := gjson.Get(payload, "usage") + if !usageResult.Exists() || !usageResult.IsObject() { + return nil + } + u := OpenAIUsage{ + InputTokens: int(gjson.Get(payload, "usage.prompt_tokens").Int()), + OutputTokens: int(gjson.Get(payload, "usage.completion_tokens").Int()), + CacheReadInputTokens: int(gjson.Get(payload, "usage.prompt_tokens_details.cached_tokens").Int()), + } + return &u +} + +func buildOpenAIChatCompletionsURL(base string) string { + normalized := strings.TrimRight(strings.TrimSpace(base), "/") + if strings.HasSuffix(normalized, "/chat/completions") { + return normalized + } + lastSlash := strings.LastIndex(normalized, "/") + lastSegment := normalized + if lastSlash >= 0 { + lastSegment = normalized[lastSlash+1:] + } + lowerSegment := strings.ToLower(lastSegment) + if len(lowerSegment) >= 2 && lowerSegment[0] == 'v' && lowerSegment[1] >= '0' && lowerSegment[1] <= '9' { + return normalized + "/chat/completions" + } + return normalized + "/v1/chat/completions" +} diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index a857d895d..78b1cbdf2 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -159,6 +159,96 @@ func TestForwardAsChatCompletions_UpstreamTierOverridesRequestFallback(t *testin require.Equal(t, "priority", *result.ServiceTier) } +func TestForwardAsChatCompletions_APIKeyWithoutResponsesSupportUsesRawChat(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(nil)) + c.Request.Header.Set("Originator", "codex") + c.Request.Header.Set("Accept-Language", "zh-CN") + + upstreamSSE := strings.Join([]string{ + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"hi"}}]}`, + "", + `data: {"id":"chatcmpl_1","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}`, + "", + "data:[DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_chat"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat.example.com/v1", + "user_agent": "custom-agent", + }, + Extra: map[string]any{"openai_responses_supported": false}, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`), "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "https://compat.example.com/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "custom-agent", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "zh-CN", upstream.lastReq.Header.Get("Accept-Language")) + require.Empty(t, upstream.lastReq.Header.Get("Originator")) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) + require.Equal(t, 12, result.Usage.InputTokens) + require.Equal(t, 3, result.Usage.OutputTokens) + require.Equal(t, 4, result.Usage.CacheReadInputTokens) +} + +func TestForwardAsChatCompletions_AcceptsCompactSSEDataPrefix(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(nil)) + + upstreamSSE := strings.Join([]string{ + `data:{"type":"response.completed","response":{"id":"resp_compact_sse","model":"gpt-5.5","output":[],"usage":{"input_tokens":7,"output_tokens":2}}}`, + "", + "data:[DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compact_sse"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.openai.com"}, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"messages":[{"role":"user","content":"hi"}]}`), "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 7, result.Usage.InputTokens) + require.Equal(t, 2, result.Usage.OutputTokens) + require.Equal(t, http.StatusOK, rec.Code) +} + func newOpenAIFastPolicySettingServiceForTest(t *testing.T, settings *OpenAIFastPolicySettings) *SettingService { t.Helper() repo := &openAIFastPolicyRepoStub{values: map[string]string{}} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 331cd6b68..43396feb7 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -245,7 +245,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( Detail: upstreamDetail, }) if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, originalModel, resp.StatusCode, resp.Header, respBody) } return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, @@ -254,7 +254,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } // Non-failover error: return Anthropic-formatted error to client - return s.handleAnthropicErrorResponse(resp, c, account) + return s.handleAnthropicErrorResponse(resp, c, account, originalModel) } // 9. Handle normal response @@ -297,8 +297,9 @@ func (s *OpenAIGatewayService) handleAnthropicErrorResponse( resp *http.Response, c *gin.Context, account *Account, + requestedModel string, ) (*OpenAIForwardResult, error) { - return s.handleCompatErrorResponse(resp, c, account, writeAnthropicError) + return s.handleCompatErrorResponse(resp, c, account, requestedModel, writeAnthropicError) } // handleAnthropicBufferedStreamingResponse reads all Responses SSE events from @@ -331,10 +332,10 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( for scanner.Scan() { line := scanner.Text() - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - payload := line[6:] var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { @@ -547,10 +548,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( if keepaliveInterval <= 0 { for scanner.Scan() { line := scanner.Text() - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - if processDataLine(line[6:]) { + if processDataLine(payload) { return resultWithUsage(), nil } } @@ -603,10 +605,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } lastDataAt = time.Now() line := ev.line - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + payload, ok := extractOpenAISSEDataLine(line) + if !ok || strings.TrimSpace(payload) == "[DONE]" { continue } - if processDataLine(line[6:]) { + if processDataLine(payload) { return resultWithUsage(), nil } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index ef062dc27..dfef0485d 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -812,6 +812,64 @@ func TestOpenAIGatewayServiceRecordUsage_ClampsActualInputTokensToZero(t *testin require.Equal(t, 0, usageRepo.lastLog.InputTokens) } +func TestOpenAIGatewayServiceRecordUsage_ZeroUsageStillPersists(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_zero_usage", + Model: "gpt-5.1", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1010}, + User: &User{ID: 2010}, + Account: &Account{ID: 3010, Platform: PlatformOpenAI}, + }) + + require.NoError(t, err) + require.Equal(t, 1, usageRepo.calls) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 0, usageRepo.lastLog.TotalTokens()) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_UnpricedModelPersistsZeroCost(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_unpriced_model", + Usage: OpenAIUsage{ + InputTokens: 5, + OutputTokens: 2, + }, + Model: "upstream-new-model-without-price", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1011}, + User: &User{ID: 2011}, + Account: &Account{ID: 3011, Platform: PlatformOpenAI}, + }) + + require.NoError(t, err) + require.Equal(t, 1, usageRepo.calls) + require.Equal(t, 0, userRepo.deductCalls) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 5, usageRepo.lastLog.InputTokens) + require.Equal(t, 2, usageRepo.lastLog.OutputTokens) + require.Zero(t, usageRepo.lastLog.TotalCost) + require.Zero(t, usageRepo.lastLog.ActualCost) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) +} + func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillsWholeSession(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index ec80324bb..ceacf094d 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1571,7 +1571,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID if !isOpenAIAccountEligibleForRequest(account, requestedModel, false) { return nil } - account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact) + account = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, account, requestedModel, requireCompact) if account == nil { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) return nil @@ -1614,7 +1614,7 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i if fresh == nil { continue } - fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, false) + fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, fresh, requestedModel, false) if fresh == nil { continue } @@ -1761,7 +1761,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } if !clearSticky && isOpenAIAccountEligibleForRequest(account, requestedModel, false) { - account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact) + account = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, account, requestedModel, requireCompact) if account == nil { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) } else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) { @@ -1836,7 +1836,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if fresh == nil { continue } - fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact) + fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, fresh, requestedModel, requireCompact) if fresh == nil { continue } @@ -1912,7 +1912,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if fresh == nil { continue } - fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact) + fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, fresh, requestedModel, requireCompact) if fresh == nil { continue } @@ -1940,7 +1940,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if fresh == nil { continue } - fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact) + fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, fresh, requestedModel, requireCompact) if fresh == nil { continue } @@ -2014,7 +2014,30 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context. return fresh } -func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, requestedModel string, requireCompact bool) *Account { +func (s *OpenAIGatewayService) isOpenAIAccountInRequestGroup(account *Account, groupID *int64) bool { + if account == nil { + return false + } + if s != nil && s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + return true + } + if groupID == nil { + return len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 + } + for _, ag := range account.AccountGroups { + if ag.GroupID == *groupID { + return true + } + } + for _, gid := range account.GroupIDs { + if gid == *groupID { + return true + } + } + return false +} + +func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, groupID *int64, account *Account, requestedModel string, requireCompact bool) *Account { if account == nil { return nil } @@ -2022,6 +2045,9 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !isOpenAIAccountEligibleForRequest(account, requestedModel, requireCompact) { return nil } + if !s.isOpenAIAccountInRequestGroup(account, groupID) { + return nil + } return account } @@ -2035,6 +2061,9 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !IsAccountVisibleToRequestUser(ctx, latest) { return nil } + if !s.isOpenAIAccountInRequestGroup(latest, groupID) { + return nil + } return latest } @@ -2156,7 +2185,18 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i } func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) { + s.handleFailoverSideEffectsForModel(ctx, resp, account, "") +} + +func (s *OpenAIGatewayService) handleFailoverSideEffectsForModel(ctx context.Context, resp *http.Response, account *Account, requestedModel string) { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if s.rateLimitService == nil { + return + } + if strings.TrimSpace(requestedModel) != "" { + s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, requestedModel, resp.StatusCode, resp.Header, body) + return + } s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) } @@ -2420,6 +2460,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco promptCacheKey = codexResult.PromptCacheKey } } + if cleanRelayState, cleanRelayModified, cleanRelayErr := s.applyOpenAICleanRelayToRequestBody(ctx, c, account, reqBody, body); cleanRelayErr != nil { + return nil, cleanRelayErr + } else if cleanRelayState != nil { + promptCacheKey = cleanRelayState.Mapping.PromptCacheKey + if cleanRelayModified { + bodyModified = true + disablePatch() + } + } // Handle max_output_tokens based on platform and account type if !isCodexCLI { @@ -2860,14 +2909,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco Detail: upstreamDetail, }) - s.handleFailoverSideEffects(ctx, resp, account) + s.handleFailoverSideEffectsForModel(ctx, resp, account, originalModel) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), } } - return s.handleErrorResponse(ctx, resp, c, account, body) + return s.handleErrorResponse(ctx, resp, c, account, body, originalModel) } defer func() { _ = resp.Body.Close() }() @@ -2939,6 +2988,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( reqStream bool, startTime time.Time, ) (*OpenAIForwardResult, error) { + cleanRelaySessionBody := body upstreamPassthroughModel := "" if isOpenAIResponsesCompactPath(c) { compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel) @@ -2993,6 +3043,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if sanitized { body = sanitizedBody } + var cleanRelayState *openAICleanRelayState + body, cleanRelayState, _, err = s.applyOpenAICleanRelayToRawBody(ctx, c, account, body, cleanRelaySessionBody) + if err != nil { + return nil, err + } + if cleanRelayState != nil { + reqStream = gjson.GetBytes(body, "stream").Bool() + } // Apply OpenAI fast policy to the passthrough body (filter/block by service_tier). // 缁熶竴浣跨敤 upstream 瑙嗚鐨?model锛氶€忎紶璺緞涓?body 宸茬粡杩?compact 鏄犲皠 + @@ -3291,6 +3349,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") } + s.applyOpenAICleanRelayHeaders(c, req) return req, nil } @@ -3300,6 +3359,9 @@ func shouldFailoverOpenAIPassthroughResponse(statusCode int, upstreamMsg string, case http.StatusTooManyRequests, 529: return true default: + if statusCode >= http.StatusInternalServerError { + return true + } return isOpenAIModelCapacityError(statusCode, upstreamMsg, upstreamBody) } } @@ -3326,7 +3388,8 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) if s.rateLimitService != nil { - _ = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + requestedModel := extractOpenAIModelFromRequestBody(requestBody) + _ = s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, requestedModel, resp.StatusCode, resp.Header, body) } appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, @@ -3372,7 +3435,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough( // Passthrough mode preserves the raw upstream error response, but runtime // account state still needs to be updated so sticky routing can stop // reusing a freshly rate-limited account. - _ = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + requestedModel := extractOpenAIModelFromRequestBody(requestBody) + _ = s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, requestedModel, resp.StatusCode, resp.Header, body) } appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, @@ -4020,6 +4084,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") } + s.applyOpenAICleanRelayHeaders(c, req) return req, nil } @@ -4030,6 +4095,7 @@ func (s *OpenAIGatewayService) handleErrorResponse( c *gin.Context, account *Account, requestBody []byte, + requestedModel string, ) (*OpenAIForwardResult, error) { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) @@ -4108,7 +4174,7 @@ func (s *OpenAIGatewayService) handleErrorResponse( // Handle upstream error (mark account status) shouldDisable := false if s.rateLimitService != nil { - shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + shouldDisable = s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, requestedModel, resp.StatusCode, resp.Header, body) } kind := "http_error" if shouldDisable { @@ -4185,6 +4251,7 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse( resp *http.Response, c *gin.Context, account *Account, + requestedModel string, writeError compatErrorWriter, ) (*OpenAIForwardResult, error) { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) @@ -4243,8 +4310,8 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse( // Track rate limits and decide whether to trigger secondary failover. shouldDisable := false if s.rateLimitService != nil { - shouldDisable = s.rateLimitService.HandleUpstreamError( - c.Request.Context(), account, resp.StatusCode, resp.Header, body, + shouldDisable = s.rateLimitService.HandleUpstreamErrorForModel( + c.Request.Context(), account, requestedModel, resp.StatusCode, resp.Header, body, ) } kind := "http_error" @@ -5317,22 +5384,33 @@ type OpenAIRecordUsageInput struct { // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { + if input == nil { + return errors.New("openai usage input is nil") + } result := input.Result - if s.rateLimitService != nil && input != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { - s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID) + if result == nil { + return errors.New("openai usage result is nil") } - - // 璺宠繃鎵€鏈?token 鍧囦负闆剁殑鐢ㄩ噺璁板綍鈥斺€斾笂娓告湭杩斿洖 usage 鏃朵笉搴斿啓鍏ユ暟鎹簱 - if result.Usage.InputTokens == 0 && result.Usage.OutputTokens == 0 && - result.Usage.CacheCreationInputTokens == 0 && result.Usage.CacheReadInputTokens == 0 && - result.Usage.ImageOutputTokens == 0 && result.ImageCount == 0 { - return nil + if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { + s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID) } apiKey := input.APIKey user := input.User account := input.Account subscription := input.Subscription + if apiKey == nil { + return errors.New("openai usage api key is nil") + } + if user == nil { + return errors.New("openai usage user is nil") + } + if account == nil { + return errors.New("openai usage account is nil") + } + if s.billingService == nil { + return errors.New("openai usage billing service is nil") + } // 璁$畻瀹為檯鐨勬柊杈撳叆token锛堝噺鍘荤紦瀛樿鍙栫殑token锛? // 鍥犱负 input_tokens 鍖呭惈浜?cache_read_tokens锛岃€岀紦瀛樿鍙栫殑token涓嶅簲鎸夎緭鍏ヤ环鏍艰璐? actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens @@ -5384,7 +5462,19 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, tokens, serviceTier) if err != nil { - cost = &CostBreakdown{ActualCost: 0} + if !isUsagePricingUnavailableError(err) { + return err + } + logger.L().With( + zap.String("component", "service.openai_gateway"), + zap.String("billing_model", billingModel), + zap.String("requested_model", input.OriginalModel), + zap.String("mapped_model", input.ChannelMappedModel), + zap.String("upstream_model", result.UpstreamModel), + zap.Int64("api_key_id", apiKey.ID), + zap.Int64("account_id", account.ID), + ).Warn("openai_usage.pricing_missing_record_zero_cost", zap.Error(err)) + cost = &CostBreakdown{BillingMode: string(BillingModeToken)} } // Determine billing type. Subscription groups never fall back to balance billing. @@ -5521,6 +5611,17 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } +func isUsagePricingUnavailableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrModelPricingUnavailable) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "no pricing available") || strings.Contains(msg, "pricing not found") +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, @@ -5863,12 +5964,19 @@ func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, p return "", false, "" } - model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + model = extractOpenAIModelFromRequestBody(body) stream = gjson.GetBytes(body, "stream").Bool() promptCacheKey = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) return model, stream, promptCacheKey } +func extractOpenAIModelFromRequestBody(body []byte) string { + if len(body) == 0 { + return "" + } + return strings.TrimSpace(gjson.GetBytes(body, "model").String()) +} + // normalizeOpenAIPassthroughOAuthBody 灏嗛€忎紶 OAuth 璇锋眰浣撴敹鏁涗负鏃ч摼璺叧閿涓猴細 // 1) 鍒犻櫎 ChatGPT internal API 涓嶆敮鎸佺殑椤跺眰 Responses 鍙傛暟 // 2) store=false 3) 闈?compact 淇濇寔 stream=true锛沜ompact 寮哄埗 stream=false diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index d7880fc5e..372a221e6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -30,6 +30,49 @@ type stubOpenAIAccountRepo struct { accounts []Account } +func openAITestAccountHasGroupMetadata(account Account) bool { + return len(account.AccountGroups) > 0 || len(account.GroupIDs) > 0 +} + +func openAITestAccountBelongsToGroup(account Account, groupID int64) bool { + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID == groupID { + return true + } + } + for _, existingGroupID := range account.GroupIDs { + if existingGroupID == groupID { + return true + } + } + return false +} + +func openAITestAccountWithGroupIfUnset(account Account, groupID int64) Account { + if openAITestAccountHasGroupMetadata(account) { + return account + } + account.GroupIDs = []int64{groupID} + account.AccountGroups = []AccountGroup{{AccountID: account.ID, GroupID: groupID}} + return account +} + +func openAITestAccountPtrWithGroupIfUnset(account *Account, groupID int64) *Account { + if account == nil { + return nil + } + grouped := openAITestAccountWithGroupIfUnset(*account, groupID) + return &grouped +} + +func openAITestAccountsWithGroupIfUnset(accounts []Account, groupID int64) []Account { + grouped := make([]Account, 0, len(accounts)) + for _, account := range accounts { + grouped = append(grouped, openAITestAccountWithGroupIfUnset(account, groupID)) + } + return grouped +} + type snapshotUpdateAccountRepo struct { stubOpenAIAccountRepo updateExtraCalls chan map[string]any @@ -79,9 +122,16 @@ func (r stubOpenAIAccountRepo) GetByID(ctx context.Context, id int64) (*Account, func (r stubOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) { var result []Account for _, acc := range r.accounts { - if acc.Platform == platform { - result = append(result, acc) + if acc.Platform != platform { + continue + } + if openAITestAccountHasGroupMetadata(acc) { + if openAITestAccountBelongsToGroup(acc, groupID) { + result = append(result, acc) + } + continue } + result = append(result, openAITestAccountWithGroupIfUnset(acc, groupID)) } return result, nil } @@ -407,7 +457,7 @@ func (c *stubGatewayCache) GetSessionString(ctx context.Context, groupID int64, return value, nil } } - return "", errors.New("not found") + return "", ErrGatewaySessionStringNotFound } func (c *stubGatewayCache) SetSessionString(ctx context.Context, groupID int64, sessionHash string, value string, ttl time.Duration) error { diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 14ed5bf2f..bf8e1464d 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -581,7 +581,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody) + return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody, requestModel) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index e65bd9c7f..e34c3dba5 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -816,7 +816,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody) + return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody, requestModel) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 512a451fb..e37567ab3 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -621,6 +621,17 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test require.WithinDuration(t, start.Add(10*time.Minute), repo.overloadCalls[0], 5*time.Second) }, }, + { + name: "oauth_503_service_unavailable", + accountType: AccountTypeOAuth, + statusCode: http.StatusServiceUnavailable, + body: `{"error":{"message":"Service temporarily unavailable","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Empty(t, repo.overloadCalls) + require.Empty(t, repo.tempCalls) + }, + }, { name: "oauth_400_model_capacity", accountType: AccountTypeOAuth, diff --git a/backend/internal/service/openai_ws_account_sticky_test.go b/backend/internal/service/openai_ws_account_sticky_test.go index 4005a921b..dbd1ab678 100644 --- a/backend/internal/service/openai_ws_account_sticky_test.go +++ b/backend/internal/service/openai_ws_account_sticky_test.go @@ -23,6 +23,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T "openai_apikey_responses_websockets_v2_enabled": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) cfg := newOpenAIWSV2TestConfig() @@ -64,6 +65,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RateLimitedMiss( "openai_apikey_responses_websockets_v2_enabled": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) cfg := newOpenAIWSV2TestConfig() @@ -85,6 +87,46 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RateLimitedMiss( require.Zero(t, boundAccountID) } +func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_CleanRelayOAuthSkipsSticky(t *testing.T) { + ctx := context.Background() + groupID := int64(23) + account := Account{ + ID: 22, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } + account = openAITestAccountWithGroupIfUnset(account, groupID) + cache := &stubGatewayCache{} + store := NewOpenAIWSStateStore(cache) + cfg := newOpenAIWSV2TestConfig() + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + openaiWSStateStore: store, + settingService: newCleanRelaySettingService(true), + } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() + + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_clean_relay", account.ID, time.Hour)) + + selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_clean_relay", "gpt-5.1", nil, false) + require.NoError(t, err) + require.Nil(t, selection, "洁净中继开启时 OAuth 账号不应继续按客户端 previous_response_id 粘连") + boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_clean_relay") + require.NoError(t, getErr) + require.Equal(t, account.ID, boundAccountID, "跳过调度粘连不应删除已有 response-account 绑定") +} + func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_DBRuntimeRecheckRateLimitedMiss(t *testing.T) { ctx := context.Background() groupID := int64(24) @@ -112,6 +154,8 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_DBRuntimeRecheck "openai_apikey_responses_websockets_v2_enabled": true, }, } + staleAccount = openAITestAccountPtrWithGroupIfUnset(staleAccount, groupID) + dbAccount = openAITestAccountWithGroupIfUnset(dbAccount, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) cfg := newOpenAIWSV2TestConfig() @@ -151,6 +195,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *test "openai_apikey_responses_websockets_v2_enabled": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) cfg := newOpenAIWSV2TestConfig() @@ -184,6 +229,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored "responses_websockets_v2_enabled": true, }, } + account = openAITestAccountWithGroupIfUnset(account, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) cfg := newOpenAIWSV2TestConfig() @@ -231,6 +277,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky( }, }, } + accounts = openAITestAccountsWithGroupIfUnset(accounts, groupID) cache := &stubGatewayCache{} store := NewOpenAIWSStateStore(cache) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 5ecd569cf..0ed6d44ba 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -1193,6 +1193,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if account != nil && account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(headers.Get("user-agent")) { headers.Set("user-agent", codexCLIUserAgent) } + applyOpenAICleanRelayWSHeaders(c, headers) return headers, sessionResolution } @@ -1922,11 +1923,23 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ) payload := s.buildOpenAIWSCreatePayload(reqBody, account) + needsToolContinuation := NeedsToolContinuation(reqBody) + payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) + turnState := "" + turnMetadata := "" + if c != nil && c.Request != nil { + turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) + turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) + } + setOpenAIWSTurnMetadata(payload, turnMetadata) + if cleanRelayState, _, cleanRelayErr := s.applyOpenAICleanRelayToRequestBody(ctx, c, account, payload, payloadAsJSONBytes(reqBody)); cleanRelayErr != nil { + return nil, wrapOpenAIWSFallback("clean_relay", cleanRelayErr) + } else if cleanRelayState != nil && cleanRelayState.CleanStart { + turnState = "" + } imageBillingConfig := resolveOpenAIResponseImageBillingConfig(openAIResponsesEndpoint, originalModel, payload) serviceTier := extractOpenAIServiceTier(payload) reasoningEffort := extractOpenAIReasoningEffort(payload, originalModel) - needsToolContinuation := NeedsToolContinuation(reqBody) - payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") @@ -1944,13 +1957,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if raw, ok := payload["stream"]; ok { streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw))) } - turnState := "" - turnMetadata := "" - if c != nil && c.Request != nil { - turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) - } - setOpenAIWSTurnMetadata(payload, turnMetadata) payloadEventType := openAIWSPayloadString(payload, "type") if payloadEventType == "" { payloadEventType = "response.create" @@ -2803,6 +2809,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } normalized = policyApplied + cleanedPayload, cleanRelayState, _, cleanRelayErr := s.applyOpenAICleanRelayToRawBody(ctx, c, account, normalized, trimmed) + if cleanRelayErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", cleanRelayErr) + } + if cleanRelayState != nil { + normalized = cleanedPayload + promptCacheKey = cleanRelayState.Mapping.PromptCacheKey + previousResponseID = openAIWSPayloadStringFromRaw(normalized, "previous_response_id") + } return openAIWSClientPayload{ payloadRaw: normalized, @@ -4222,6 +4237,9 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return nil, nil } + if s.isOpenAICleanRelayActive(ctx, account) { + return nil, nil + } // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, // 以保持“回滚到 HTTP”后的历史行为一致性。 if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { @@ -4234,7 +4252,7 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( if requestedModel != "" && !account.IsModelSupported(requestedModel) { return nil, nil } - account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact) + account = s.recheckSelectedOpenAIAccountFromDB(ctx, groupID, account, requestedModel, requireCompact) if account == nil { _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return nil, nil 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 7e86d2328..e0ad2f485 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -465,13 +465,17 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), openaiWSPassthroughDialer: captureDialer, + settingService: newCleanRelaySettingService(true), } + defer func() { + svc.settingService = newCleanRelaySettingService(false) + }() account := &Account{ ID: 452, Name: "openai-ingress-passthrough", Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, @@ -479,7 +483,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR "api_key": "sk-test", }, Extra: map[string]any{ - "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough, + "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough, + "chatgpt_account_id": "chatgpt-account-test", }, } @@ -510,7 +515,13 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR req := r.Clone(r.Context()) req.Header = req.Header.Clone() req.Header.Set("User-Agent", "unit-test-agent/1.0") + req.Header.Set(openAICleanRelayInstallationField, "client-iid") + req.Header.Set("session_id", "client-session") + req.Header.Set("conversation_id", "client-conversation") + req.Header.Set(openAIWSTurnStateHeader, "client-turn-state") ginCtx.Request = req + groupID := int64(901) + ginCtx.Set("api_key", &APIKey{ID: 900, GroupID: &groupID}) readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) msgType, firstMessage, readErr := conn.Read(readCtx) @@ -537,7 +548,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false,"service_tier":"fast","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1024x1024"}]}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false,"service_tier":"fast","prompt_cache_key":"client-cache","previous_response_id":"resp_client_old","client_metadata":{"x-codex-installation-id":"client-body-iid"},"input":[{"type":"reasoning","encrypted_content":"sealed"},{"type":"input_text","text":"hello"}],"tools":[{"type":"image_generation","model":"gpt-image-2","size":"1024x1024"}]}`)) cancelWrite() require.NoError(t, err) @@ -578,14 +589,21 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR require.Equal(t, "1K", result.ImageSize) require.Equal(t, "gpt-image-2", result.BillingModel) require.Equal(t, "gpt-image-2", result.Model) - require.NotNil(t, result.ServiceTier) - require.Equal(t, "priority", *result.ServiceTier) + require.Nil(t, result.ServiceTier) case <-time.After(2 * time.Second): t.Fatal("未收到 passthrough turn 结果回调") } require.Equal(t, 1, captureDialer.DialCount(), "passthrough 模式应直接建立上游 websocket") require.Len(t, upstreamConn.writes, 1, "passthrough 模式应透传首条 response.create") + require.False(t, upstreamConn.writes[0]["previous_response_id"] != nil, "洁净中继 cold start 应清理客户端 previous_response_id") + require.Equal(t, "input_text", upstreamConn.writes[0]["input"].([]any)[0].(map[string]any)["type"]) + require.Equal(t, openAICleanRelayInstallationID(account.ID), upstreamConn.writes[0]["client_metadata"].(map[string]any)[openAICleanRelayInstallationField]) + require.NotEqual(t, "client-cache", upstreamConn.writes[0]["prompt_cache_key"]) + require.Equal(t, openAICleanRelayInstallationID(account.ID), captureDialer.lastHeaders.Get(openAICleanRelayInstallationField)) + require.NotEqual(t, "client-session", captureDialer.lastHeaders.Get("session_id")) + require.NotEqual(t, "client-conversation", captureDialer.lastHeaders.Get("conversation_id")) + require.Empty(t, captureDialer.lastHeaders.Get(openAIWSTurnStateHeader)) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index dd09198b0..bfa6a12b1 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -23,9 +23,9 @@ type openAIWSClientFrameConn struct { } // openAIWSPolicyEnforcingFrameConn wraps a client-side FrameConn and runs -// every client→upstream frame through the OpenAI Fast Policy. It is the -// passthrough-relay equivalent of the parseClientPayload integration in the -// ingress session path. filter returns: +// every client→upstream frame through the passthrough request filter. It is +// the relay equivalent of the parseClientPayload integration in the ingress +// session path. filter returns: // - newPayload, nil, nil: forward the (possibly mutated) payload // - _, *OpenAIFastBlockedError, nil: block — the wrapper sends an error // event via onBlock and surfaces a transport-level error so the relay @@ -250,6 +250,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, blocked.Message, blocked) } firstClientMessage = updatedFirst + cleanedFirst, cleanRelayState, _, cleanRelayErr := s.applyOpenAICleanRelayToRawBody(ctx, c, account, firstClientMessage, firstClientMessage) + if cleanRelayErr != nil { + return fmt.Errorf("apply openai clean relay on first ws frame: %w", cleanRelayErr) + } + if cleanRelayState != nil { + firstClientMessage = cleanedFirst + } // 在 policy filter 之后再提取 service_tier 用于 billing 上报:filter // 命中时 service_tier 已经从 firstClientMessage 中删除,billing 应当 @@ -364,6 +371,14 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( model = capturedSessionModel } out, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, model, payload) + if policyErr == nil && blocked == nil && + strings.TrimSpace(gjson.GetBytes(out, "type").String()) == "response.create" { + cleanedOut, _, _, cleanRelayErr := s.applyOpenAICleanRelayToRawBody(ctx, c, account, out, payload) + if cleanRelayErr != nil { + return out, nil, cleanRelayErr + } + out = cleanedOut + } // 多轮 passthrough billing:仅在成功(non-block / non-err) // 的 response.create 帧上更新 requestServiceTierPtr,使用 // filter 处理后的 payload,与首帧 policy-after-extract 语义 @@ -379,7 +394,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // 覆盖(Store(nil)),因为 OpenAI 上游对该帧实际不传 // service_tier 时按 default 处理,billing 应如实反映。 if policyErr == nil && blocked == nil && - strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + strings.TrimSpace(gjson.GetBytes(out, "type").String()) == "response.create" { requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(out)) frameModel := strings.TrimSpace(gjson.GetBytes(out, "model").String()) if frameModel == "" { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index c45e054f9..b81996ccc 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -63,6 +63,7 @@ const ( openAI403DisableThreshold = 3 openAI403CounterWindowMinutes = 180 openAIModelCapacityCooldown = time.Minute + upstreamModelNotFoundCooldown = 30 * time.Minute ) var cloudflareChallengeCooldownSteps = []time.Duration{ @@ -309,6 +310,75 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc return shouldDisable } +func (s *RateLimitService) HandleUpstreamErrorForModel(ctx context.Context, account *Account, requestedModel string, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) { + if s.handleUpstreamModelNotFound(ctx, account, requestedModel, statusCode, responseBody) { + return true + } + return s.HandleUpstreamError(ctx, account, statusCode, headers, responseBody) +} + +func (s *RateLimitService) handleUpstreamModelNotFound(ctx context.Context, account *Account, requestedModel string, statusCode int, responseBody []byte) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + if !account.ShouldHandleErrorCode(statusCode) { + return false + } + if !isUpstreamModelNotFoundError(statusCode, responseBody) { + return false + } + modelKey := modelRateLimitKeyForUpstreamModelNotFound(ctx, account, requestedModel) + if modelKey == "" { + return false + } + resetAt := time.Now().Add(upstreamModelNotFoundCooldown) + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt); err != nil { + slog.Warn("upstream_model_not_found_set_model_rate_limit_failed", "account_id", account.ID, "model", modelKey, "error", err) + return true + } + slog.Info("upstream_model_not_found_model_rate_limited", "account_id", account.ID, "model", modelKey, "reset_at", resetAt) + return true +} + +func isUpstreamModelNotFoundError(statusCode int, body []byte) bool { + if statusCode != http.StatusNotFound { + return false + } + normalized := normalizeModelNotFoundBody(body) + if normalized == "" || !strings.Contains(normalized, "model") { + return false + } + return strings.Contains(normalized, "model not found") || + strings.Contains(normalized, "unknown model") || + strings.Contains(normalized, "not found") +} + +func normalizeModelNotFoundBody(body []byte) string { + if len(body) == 0 { + return "" + } + normalized := strings.ToLower(string(body)) + normalized = strings.NewReplacer("_", " ", "-", " ", "\n", " ", "\r", " ", "\t", " ").Replace(normalized) + return strings.Join(strings.Fields(normalized), " ") +} + +func modelRateLimitKeyForUpstreamModelNotFound(ctx context.Context, account *Account, requestedModel string) string { + modelKey := strings.TrimSpace(requestedModel) + if account == nil || modelKey == "" { + return modelKey + } + mapped := strings.TrimSpace(account.GetMappedModel(modelKey)) + if mapped != "" { + modelKey = mapped + } + if account.Platform == PlatformAntigravity { + if resolved := strings.TrimSpace(resolveFinalAntigravityModelKey(ctx, account, requestedModel)); resolved != "" { + modelKey = resolved + } + } + return modelKey +} + func (s *RateLimitService) handleOpenAIModelCapacityError(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool { if s == nil || s.accountRepo == nil || account == nil || account.Platform != PlatformOpenAI { return false diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go index 3b1d30b9a..8511cdb22 100644 --- a/backend/internal/service/ratelimit_service_401_test.go +++ b/backend/internal/service/ratelimit_service_401_test.go @@ -18,6 +18,7 @@ type rateLimitAccountRepoStub struct { setErrorCalls int tempCalls int updateCredentialsCalls int + modelRateLimitCalls []modelRateLimitCall lastCredentials map[string]any lastErrorMsg string lastTempReason string @@ -43,6 +44,11 @@ func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int return nil } +func (r *rateLimitAccountRepoStub) SetModelRateLimit(ctx context.Context, id int64, modelKey string, resetAt time.Time) error { + r.modelRateLimitCalls = append(r.modelRateLimitCalls, modelRateLimitCall{accountID: id, modelKey: modelKey, resetAt: resetAt}) + return nil +} + type tokenCacheInvalidatorRecorder struct { accounts []*Account err error diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index cbfb116d1..be13d8fb9 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -335,6 +335,85 @@ func TestRateLimitService_HandleUpstreamError_OpenAICapacityTempUnschedsPoolMode require.Contains(t, repo.lastTempReason, "openai_model_capacity") } +func TestRateLimitService_HandleUpstreamErrorForModel_OpenAI404SetsModelCooldown(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 204, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + before := time.Now() + shouldDisable := service.HandleUpstreamErrorForModel( + context.Background(), + account, + "gpt-missing", + http.StatusNotFound, + http.Header{}, + []byte(`{"error":{"message":"Model not found","type":"invalid_request_error"}}`), + ) + after := time.Now() + + require.True(t, shouldDisable) + require.Len(t, repo.modelRateLimitCalls, 1) + call := repo.modelRateLimitCalls[0] + require.Equal(t, account.ID, call.accountID) + require.Equal(t, "gpt-missing", call.modelKey) + require.True(t, !call.resetAt.Before(before.Add(upstreamModelNotFoundCooldown))) + require.True(t, !call.resetAt.After(after.Add(upstreamModelNotFoundCooldown))) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) +} + +func TestRateLimitService_HandleUpstreamErrorForModel_OpenAI404WithoutRequestedModelFallsBack(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 205, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + shouldDisable := service.HandleUpstreamErrorForModel( + context.Background(), + account, + "", + http.StatusNotFound, + http.Header{}, + []byte(`{"error":{"message":"Model not found","type":"invalid_request_error"}}`), + ) + + require.False(t, shouldDisable) + require.Empty(t, repo.modelRateLimitCalls) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) +} + +func TestRateLimitService_HandleUpstreamErrorForModel_OpenAI404NonModelBodyDoesNotCooldown(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 206, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + shouldDisable := service.HandleUpstreamErrorForModel( + context.Background(), + account, + "gpt-5", + http.StatusNotFound, + http.Header{}, + []byte(`{"error":{"message":"Endpoint not found","type":"invalid_request_error"}}`), + ) + + require.False(t, shouldDisable) + require.Empty(t, repo.modelRateLimitCalls) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) +} + func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) { // Test when only secondary has data, no window_minutes sUsed := 60.0 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2a56a1516..b1e838ade 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -98,6 +98,7 @@ type cachedGatewayForwardingSettings struct { fingerprintUnification bool metadataPassthrough bool cchSigning bool + openAICleanRelay bool anthropicCacheTTL1hInjection bool expiresAt int64 // unix nano } @@ -1714,6 +1715,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) + updates[SettingKeyOpenAICleanRelayEnabled] = strconv.FormatBool(settings.OpenAICleanRelayEnabled) updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource @@ -1793,6 +1795,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { fingerprintUnification: settings.EnableFingerprintUnification, metadataPassthrough: settings.EnableMetadataPassthrough, cchSigning: settings.EnableCCHSigning, + openAICleanRelay: settings.OpenAICleanRelayEnabled, anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) @@ -1957,7 +1960,7 @@ func parseMasterDataPlaneEnabled(settings map[string]string) bool { } type gatewayForwardingSettingsResult struct { - fp, mp, cch, cacheTTL1h bool + fp, mp, cch, cleanRelay, cacheTTL1h bool } func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { @@ -1967,6 +1970,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) fp: cached.fingerprintUnification, mp: cached.metadataPassthrough, cch: cached.cchSigning, + cleanRelay: cached.openAICleanRelay, cacheTTL1h: cached.anthropicCacheTTL1hInjection, } } @@ -1978,6 +1982,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) fp: cached.fingerprintUnification, mp: cached.metadataPassthrough, cch: cached.cchSigning, + cleanRelay: cached.openAICleanRelay, cacheTTL1h: cached.anthropicCacheTTL1hInjection, }, nil } @@ -1988,6 +1993,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) SettingKeyEnableFingerprintUnification, SettingKeyEnableMetadataPassthrough, SettingKeyEnableCCHSigning, + SettingKeyOpenAICleanRelayEnabled, SettingKeyEnableAnthropicCacheTTL1hInjection, }) if err != nil { @@ -1996,6 +2002,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) fingerprintUnification: true, metadataPassthrough: false, cchSigning: false, + openAICleanRelay: false, anthropicCacheTTL1hInjection: false, expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) @@ -2007,15 +2014,17 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) } mp := values[SettingKeyEnableMetadataPassthrough] == "true" cch := values[SettingKeyEnableCCHSigning] == "true" + cleanRelay := values[SettingKeyOpenAICleanRelayEnabled] == "true" cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: fp, metadataPassthrough: mp, cchSigning: cch, + openAICleanRelay: cleanRelay, anthropicCacheTTL1hInjection: cacheTTL1h, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) - return gatewayForwardingSettingsResult{fp: fp, mp: mp, cch: cch, cacheTTL1h: cacheTTL1h}, nil + return gatewayForwardingSettingsResult{fp: fp, mp: mp, cch: cch, cleanRelay: cleanRelay, cacheTTL1h: cacheTTL1h}, nil }) if r, ok := val.(gatewayForwardingSettingsResult); ok { return r @@ -2036,6 +2045,11 @@ func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Conte return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h } +// IsOpenAICleanRelayEnabled 检查是否启用 OpenAI 洁净中继模式。 +func (s *SettingService) IsOpenAICleanRelayEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).cleanRelay +} + // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) @@ -2528,6 +2542,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", + SettingKeyOpenAICleanRelayEnabled: "false", SettingKeyEnableAnthropicCacheTTL1hInjection: "false", SettingKeyUserPrivateGroupDailyLimitUSD: "0", SettingKeyUserPrivateGroupWeeklyLimitUSD: "0", @@ -2935,6 +2950,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" + result.OpenAICleanRelayEnabled = settings[SettingKeyOpenAICleanRelayEnabled] == "true" result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" // Web search emulation: quick enabled check from the JSON config diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 46f5e7c98..50538ac8c 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -193,6 +193,7 @@ type SystemSettings struct { EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) + OpenAICleanRelayEnabled bool // 是否启用 OpenAI 洁净中继模式(默认 false) EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) // Web Search Emulation diff --git "a/docs/Pixel\345\244\232\345\256\236\344\276\213\350\247\243\345\206\263\346\226\271\346\241\210.md" "b/docs/Pixel\345\244\232\345\256\236\344\276\213\350\247\243\345\206\263\346\226\271\346\241\210.md" new file mode 100644 index 000000000..207aaea6c --- /dev/null +++ "b/docs/Pixel\345\244\232\345\256\236\344\276\213\350\247\243\345\206\263\346\226\271\346\241\210.md" @@ -0,0 +1,2588 @@ +# Sub2API 主站-子站动态账号调度架构设计方案 + +## 0. 当前实现状态(以代码为准) + +本节记录当前仓库已经落地的能力,避免文档反向牵引实现。 + +已确认完成: + +- 主站数据库迁移:`backend/migrations/141_subsite_control_plane.sql` 已新增 `subsites`、`account_leases`、`quota_reservations`、`subsite_heartbeats`。 +- 主站内部控制面接口:当前实际路径为 `/api/internal/...`,由 `backend/internal/server/routes/subsite_internal.go` 注册。 +- 主站后台管理接口:当前实际路径为 `/api/admin/subsites...`,支持子站创建、编辑、激活、暂停、恢复和租约管理。 +- 子站 agent:入口为 `backend/cmd/subsite-agent/main.go`,配置来自 YAML 或 `SUBSITE_*` 环境变量。 +- 子站 agent 数据面:已支持 `/v1/messages`、`/v1/chat/completions`、`/v1/responses`、OpenAI images、Gemini `/v1beta/models/*path`,并支持 Responses WebSocket。 +- 子站本地 usage 队列:使用 SQLite 持久化,路径由 `SUBSITE_USAGE_QUEUE_PATH` 或配置文件控制。 +- 子站部署模板:`deploy/docker-compose.subsite-agent.yml`、`deploy/.env.subsite.example`、`deploy/sub2api-subsite-agent.service`、`deploy/subsite-agent.env.example`。 + +当前第一阶段不作为阻塞项: + +- 子站自助注册 `/register`、join token 自动接入。 +- 自动账号调度、自动扩缩容、复杂地域路由。 +- mTLS。 +- 统一 API 域名无感分流。 +- 多主站高可用。 + +生产测试前必须人工完成: + +- 在主站后台创建子站,保存一次性显示的 `subsite_secret`。 +- 在主站后台激活子站。 +- 为子站手动创建至少一个账号租约。 +- 在子站服务器填入 `SUBSITE_ID`、`SUBSITE_MASTER_SECRET`、`SUBSITE_MASTER_URL`、`SUBSITE_PUBLIC_URL`。 +- 使用真实或受控测试账号跑通一次非流式、一次流式、一次 usage 重复上报幂等验证。 + +## 1. 目标说明 + +当前所有上游账号统一由 **主站** 管理,包括 Claude、OpenAI、Gemini 等账号或调用凭证。 +希望通过 **主站 + 多个子站** 的方式实现请求分发、账号动态调度、用户日志同步和全局额度控制。 + +核心目标: + +- 主站管理所有上游账号; +- 主站维护统一账号池; +- 主站根据子站负载、健康状态、地域、模型能力等因素动态分配账号; +- 每个子站独立部署; +- 用户登录、前端操作、额度查看、账号管理都在主站完成; +- 用户实际模型请求由子站处理; +- 主站只承载控制面流量,不承载模型流式响应和大体积响应; +- 子站使用主站分配的动态账号凭证调用上游; +- 子站将用户使用记录、请求日志、用量数据同步给主站; +- 用户额度以主站为准; +- 主站做全局限流、全局并发、账号调度和风控; +- 一个上游账号同一时间只能分配给一个子站,不能多个子站共享。 + +--- + +## 2. 总体架构 + +整体架构可以理解为: + +```text +主站 = 控制面 / 管理面 / 权威数据中心 +子站 = 数据面 / 请求执行节点 / 边缘调用节点 +``` + +架构示意: + +```text + ┌────────────────────────┐ + │ 主站 Master │ + │ │ + │ 用户系统 │ + │ 登录/前端/管理后台 │ + │ 用户额度 │ + │ API Key 管理 │ + │ 上游账号池 │ + │ 子站管理 │ + │ 账号调度 │ + │ 全局限流/并发 │ + │ 日志中心 │ + │ 计费结算 │ + │ 风控中心 │ + └───────────┬────────────┘ + │ + 账号租约 / 请求票据 / 日志同步 / 心跳 + │ + ┌─────────────────────────────┼─────────────────────────────┐ + │ │ │ +┌───────▼────────┐ ┌────────▼───────┐ ┌────────▼───────┐ +│ 子站 A │ │ 子站 B │ │ 子站 C │ +│ 请求执行节点 │ │ 请求执行节点 │ │ 请求执行节点 │ +│ 本地限流 │ │ 本地限流 │ │ 本地限流 │ +│ 调用上游 │ │ 调用上游 │ │ 调用上游 │ +│ 本地日志缓存 │ │ 本地日志缓存 │ │ 本地日志缓存 │ +│ 心跳上报 │ │ 心跳上报 │ │ 心跳上报 │ +└───────┬────────┘ └────────┬───────┘ └────────┬───────┘ + │ │ │ + └─────────────────────────────┼─────────────────────────────┘ + │ + ┌───────▼────────┐ + │ 上游 AI 服务 │ + │ Claude/OpenAI │ + │ Gemini/其他 │ + └────────────────┘ +``` + +为了解决主服务器带宽瓶颈,必须把控制面和数据面拆开: + +```text +控制面:子站 ↔ 主站 +用途:鉴权、额度预冻结、账号租约、心跳、日志同步、结算、风控指令 + +数据面:用户 ↔ 子站 ↔ 上游 AI 服务 +用途:模型请求、流式响应、大体积响应、WebSocket 长连接 +``` + +主站不应该作为常态模型请求代理。只要用户请求和流式响应仍然经过主站,主服务器带宽瓶颈就没有真正解决。 + +--- + +## 3. 核心原则 + +### 3.1 主站是唯一权威数据源 + +所有长期数据都应该以主站为准: + +- 用户数据; +- 用户额度; +- 用户 API Key; +- 上游账号; +- 账号状态; +- 子站状态; +- 请求日志; +- 用量记录; +- 计费记录; +- 风控记录。 + +子站只作为执行节点,不应该成为长期数据源。 + +--- + +### 3.2 子站只负责请求执行 + +子站主要负责: + +- 接收被调度过来的用户请求; +- 校验主站签发的请求票据; +- 使用主站分配的账号凭证调用上游; +- 记录本地临时日志; +- 向主站上报请求结果和用量; +- 定期向主站发送心跳; +- 接收主站账号分配、回收、禁用等指令。 + +子站不应该负责: + +- 用户注册; +- 用户登录; +- 用户充值; +- 用户长期额度管理; +- 用户长期日志查询; +- 上游账号永久管理; +- 管理后台。 + +--- + +### 3.3 一个账号同一时间只能属于一个子站 + +这是整个方案中非常关键的约束。 + +```text +账号 A 当前分配给子站 1 +则账号 A 不能同时出现在子站 2、子站 3 +``` + +这样可以避免: + +- 多个子站同时使用同一个账号; +- 上游账号并发失控; +- 用量统计错乱; +- 账号被风控; +- 无法判断哪个子站消耗了额度; +- 回收账号时状态混乱。 + +建议在数据库层面也强制约束: + +```text +upstream_account.owner_subsite_id 同一时间只能有一个值 +account_lease 同一账号只能存在一个 active 租约 +``` + +--- + +### 3.4 账号分配使用租约机制 + +不要简单地把账号永久分给某个子站,推荐使用 **租约机制**。 + +主站给子站的不是永久账号所有权,而是一段时间内的使用权。 + +示例: + +```json +{ + "lease_id": "lease_abc123", + "account_id": "acc_001", + "subsite_id": "site_001", + "status": "active", + "assigned_at": "2026-05-09T10:00:00Z", + "expires_at": "2026-05-09T10:30:00Z", + "max_concurrency": 5, + "max_tokens": 500000, + "max_requests": 1000 +} +``` + +租约好处: + +- 主站可以随时知道账号归属; +- 子站掉线后账号不会永久占用; +- 可设置过期时间; +- 可限制并发、请求数、token 数; +- 可回收、续租、释放; +- 便于做全局调度。 + +--- + +### 3.5 额度以主站为准 + +用户额度不能以子站为准。 + +推荐模式: + +```text +主站预冻结额度 +子站执行请求 +子站上报实际用量 +主站最终结算 +``` + +这样可以避免: + +- 用户余额不足但请求已经发出; +- 多个子站并发请求导致额度穿透; +- 子站同步延迟导致超用; +- 子站异常不上报导致主站额度不准。 + +--- + +### 3.6 子站不能直接写主站数据库 + +不推荐: + +```text +子站 → 直接连接主站数据库 → INSERT/UPDATE +``` + +推荐: + +```text +子站 → 主站 API → 主站校验 → 写入数据库 +``` + +或者: + +```text +子站 → 消息队列 → 主站消费 → 写入数据库 +``` + +原因: + +- 降低数据库暴露风险; +- 避免子站被攻破后直接操作主库; +- 方便鉴权、签名、限流; +- 方便版本兼容; +- 方便日志幂等处理; +- 方便审计。 + +--- + +### 3.7 主站默认不承载数据面流量 + +本方案的首要目标是解决主服务器带宽限制,因此主站不应作为常态反向代理承接模型请求和流式响应。 + +推荐边界: + +```text +主站处理小流量控制消息: +- 用户登录; +- API Key 创建和管理; +- 子站鉴权; +- 请求授权; +- 额度预冻结; +- 账号租约下发; +- 请求日志接收; +- 用量结算; +- 后台管理操作。 + +子站处理大流量数据消息: +- 模型请求体; +- 模型响应体; +- SSE 流式响应; +- WebSocket 长连接; +- 文件、图片等大体积内容; +- 上游请求和响应。 +``` + +如果第一阶段仍采用 `用户 → 主站 → 子站 → 上游`,主站仍会被流式响应和大响应占满带宽,只能作为兼容、灰度、调试或应急模式,不能作为解决带宽问题的主路径。 + +--- + +## 4. 主站职责 + +主站负责所有核心管理能力。 + +### 4.1 用户系统 + +包括: + +- 用户注册; +- 用户登录; +- 用户信息管理; +- 用户权限; +- 用户分组; +- 用户封禁; +- 用户 API Key 管理; +- 用户套餐; +- 用户余额; +- 用户额度。 + +--- + +### 4.2 前端和管理后台 + +所有用户登录、前端操作、后台管理都在主站进行。 + +包括: + +- 用户登录页面; +- 用户控制台; +- 额度查看; +- 请求记录查看; +- API Key 创建; +- 账号管理后台; +- 子站管理后台; +- 日志查询; +- 风控面板; +- 统计报表。 + +子站不提供用户登录和管理页面。 + +--- + +### 4.3 上游账号池管理 + +主站统一维护所有上游账号,包括: + +- Claude 账号; +- OpenAI 账号; +- Gemini 账号; +- 其他 AI 服务账号; +- API Key; +- Cookie; +- Token; +- OAuth 凭证; +- 账号健康状态; +- 账号可用模型; +- 账号额度; +- 账号并发限制。 + +账号状态建议: + +```text +idle 空闲,可分配 +assigned 已分配给子站 +draining 准备回收,不接收新请求 +disabled 禁用 +error 异常 +cooldown 冷却中 +checking 健康检查中 +``` + +--- + +### 4.4 子站管理 + +主站维护所有子站信息: + +- 子站 ID; +- 子站名称; +- 子站域名; +- 子站地域; +- 子站状态; +- 子站密钥; +- 子站证书; +- 最大 QPS; +- 最大并发; +- 当前负载; +- 当前分配账号数; +- 当前未同步日志数; +- 最近心跳时间; +- 子站版本; +- 子站健康评分。 + +子站状态建议: + +```text +pending 已在主站创建,等待管理员激活 +active 正式承接用户请求 +maintenance 维护中,不接收新请求 +unhealthy 心跳异常、错误率过高或待人工检查 +disabled 禁用,不允许授权、不允许续租 +``` + +当前实现中,新建子站进入 `pending`。agent 心跳只更新心跳时间、版本和健康信息,不会把 `pending` 自动提升为 `active`。必须由管理员在后台激活后,子站才会通过 authorize 校验并承接新请求。 + +--- + +### 4.5 账号调度 + +主站根据以下因素动态调度账号: + +- 子站负载; +- 子站 QPS; +- 子站并发; +- 子站延迟; +- 子站错误率; +- 子站地域; +- 子站可用性; +- 子站健康评分; +- 账号健康评分; +- 账号支持模型; +- 账号剩余额度; +- 账号错误率; +- 账号冷却状态; +- 用户请求模型; +- 用户所在区域; +- 全局并发策略。 + +--- + +### 4.6 全局限流和全局并发 + +主站需要控制: + +- 用户级 QPS; +- 用户级并发; +- API Key 级 QPS; +- API Key 级并发; +- 子站级 QPS; +- 子站级并发; +- 账号级并发; +- 模型级并发; +- 全局请求量; +- 全局 token 消耗速度。 + +示例: + +```text +用户 user_001 最大并发 5 +子站 site_001 最大并发 100 +账号 acc_001 最大并发 3 +模型 claude-sonnet 全局最大并发 500 +``` + +--- + +### 4.7 日志中心和计费中心 + +主站最终保存所有请求日志和计费数据: + +- request_id; +- user_id; +- api_key_id; +- subsite_id; +- account_id; +- lease_id; +- provider; +- model; +- prompt_tokens; +- completion_tokens; +- total_tokens; +- cost; +- status; +- error_code; +- latency_ms; +- created_at; +- completed_at。 + +--- + +## 5. 子站职责 + +子站是请求执行节点,职责尽量简单。推荐子站部署精简代理程序,而不是部署完整主站项目。 + +### 5.1 接收请求 + +子站接收用户直接发起的模型请求,并在执行前向主站申请授权。 + +请求可以来自: + +1. 用户客户端携带 API Key 直接请求子站,子站再向主站 authorize; +2. 用户客户端携带主站签发的 ticket 直连子站; +3. 单域名入口转发到某个子站; +4. 管理后台调试或应急时,由主站代理到子站。 + +--- + +### 5.2 校验请求授权 + +子站必须校验主站返回的授权上下文或主站签发的请求票据。 + +校验内容包括: + +- 授权上下文或 ticket 是否由主站签发; +- 是否过期; +- 是否属于当前子站; +- 是否绑定当前用户; +- 是否绑定当前 request_id; +- 是否绑定指定模型; +- 是否绑定当前账号租约; +- 是否超过授权上限; +- ticket 模式下是否已经被使用。 + +无合法授权的请求,子站必须拒绝。 + +--- + +### 5.3 使用主站分配的账号 + +子站只能使用主站分配给自己的账号。 + +子站不能: + +- 使用未分配账号; +- 使用已过期租约账号; +- 使用其他子站账号; +- 使用已被主站回收账号; +- 使用 disabled/error/cooldown 状态账号。 + +--- + +### 5.4 本地日志缓存 + +子站请求执行完成后,应先在本地持久化日志,再同步主站。 + +本地缓存可以使用: + +- SQLite; +- PostgreSQL; +- Redis Stream; +- 文件队列; +- NATS JetStream; +- Kafka; +- RabbitMQ。 + +小规模第一版可以使用 SQLite 或本地数据库。 + +--- + +### 5.5 日志同步 + +子站将请求记录、用量数据同步给主站。 + +必须支持: + +- 批量上报; +- 失败重试; +- 幂等; +- 去重; +- 补传; +- 对账; +- 同步状态跟踪。 + +--- + +### 5.6 心跳上报 + +子站定期向主站上报状态。 + +心跳内容示例: + +```json +{ + "subsite_id": "site_001", + "version": "1.2.3", + "status": "active", + "active_requests": 32, + "qps": 15.2, + "error_rate": 0.03, + "avg_latency_ms": 4200, + "assigned_accounts": 12, + "pending_sync_logs": 5, + "cpu_usage": 0.61, + "memory_usage": 0.72, + "timestamp": "2026-05-09T10:00:00Z" +} +``` + +--- + +### 5.7 精简子站代理程序 + +子站不建议部署完整主站项目。完整项目包含面板、用户、支付、账号管理、报表、后台任务等能力,暴露在子站会扩大攻击面,也会增加维护和配置复杂度。 + +推荐做独立的轻量代理程序,例如: + +```text +sub2api-server 主站完整服务 +sub2api-subsite-agent 子站精简代理 +``` + +子站代理程序只包含: + +```text +HTTP/SSE/WebSocket 代理入口 +主站 authorize 客户端 +主站 usage batch 客户端 +主站 heartbeat 客户端 +账号租约本地缓存 +上游凭证内存缓存 +本地日志队列 +本地限流和并发槽 +draining 排空逻辑 +健康检查接口 +``` + +子站代理程序不包含: + +```text +用户注册/登录 +用户控制台 +管理后台 +支付系统 +套餐系统 +API Key 创建 +上游账号永久管理 +长期日志查询 +主数据库直连能力 +全局调度决策 +``` + +第一版可采用单二进制部署: + +```text +sub2api-subsite-agent + --config subsite.yaml +``` + +配置示例: + +```yaml +subsite: + id: site_us_01 + public_url: https://us-01-api.example.com + region: us + capabilities: + - anthropic + - openai + max_qps: 100 + max_concurrency: 200 + +master: + base_url: https://master.example.com + subsite_secret: ${SUBSITE_SECRET} + +local: + listen_addr: 0.0.0.0:8080 + queue_path: ./data/usage_queue.db +``` + +主站面板只管理子站元数据、账号分配、租约、状态和风控策略。子站代理程序通过心跳拉取或接收这些控制信息。 + +--- + +### 5.8 子站 URL 暴露方案 + +用户如何访问子站有三种可选方案。 + +#### 方案 A:主站展示可用子站 URL + +```text +用户登录主站 +主站展示当前推荐入口: +https://us-01-api.example.com +https://hk-01-api.example.com +https://sg-01-api.example.com +用户复制其中一个作为 API Base URL +``` + +优点: + +- 实现最简单; +- 主站不承载数据面流量; +- 子站问题容易定位; +- 每个子站可以独立限流、维护、下线。 + +缺点: + +- 用户需要选择或切换 URL; +- 子站 URL 会暴露; +- 子站故障时用户需要换入口。 + +适合作为第一阶段 MVP。 + +#### 方案 B:一个统一 API 域名,DNS 或边缘网关分流 + +```text +用户只使用: +https://api.example.com + +DNS / CDN / 边缘网关 根据地区、健康状态、权重把流量转到: +https://us-01-api.example.com +https://hk-01-api.example.com +https://sg-01-api.example.com +``` + +优点: + +- 用户体验最好; +- 用户不用理解子站; +- 可以按地域和健康状态自动切换。 + +缺点: + +- 如果统一入口本身回源代理所有数据流,可能再次形成带宽瓶颈; +- DNS 切换有缓存延迟; +- CDN/边缘网关成本和配置复杂度更高; +- SSE/WebSocket 需要确认网关完整支持长连接。 + +注意:统一域名可以用,但统一入口不能部署在主站服务器上做全量反代。否则主站带宽瓶颈会回来。统一入口应放在 DNS、CDN、Anycast、云负载均衡或独立边缘网关层。 + +#### 方案 C:主站返回推荐子站,客户端自动切换 + +```text +用户请求主站轻量接口: +GET /api/client/subsite/recommend + +主站返回: +{ + "base_url": "https://hk-01-api.example.com", + "expires_at": "2026-05-09T10:10:00Z" +} + +客户端后续模型请求直接打到该 base_url +``` + +优点: + +- 主站只返回很小的调度结果; +- 不承载模型数据面; +- 可以动态按用户地区、子站健康、账号租约推荐入口。 + +缺点: + +- 需要客户端支持自动发现; +- 对只支持固定 Base URL 的第三方客户端不友好。 + +推荐落地顺序: + +```text +第一阶段:方案 A,主站面板展示多个可用子站 URL +第二阶段:方案 C,为自有客户端提供自动推荐入口 +第三阶段:方案 B,用统一域名 + 边缘层实现无感分流 +``` + +--- + +## 6. 推荐请求流程 + +### 6.1 用户登录和前端操作 + +```text +用户 → 主站前端 +主站完成登录、额度展示、API Key 管理、请求记录查询 +``` + +所有用户前端操作都在主站进行。 + +--- + +### 6.2 用户发起模型请求 + +推荐第一阶段直接采用 **子站数据面直连模式**。用户的模型请求不再进入主站,而是直接请求子站;子站在执行前向主站请求授权和额度预冻结。 + +```text +1. 用户在主站创建 API Key +2. 用户选择或获得一个可用子站入口 +3. 用户携带 API Key 直接请求子站 +4. 子站只读取必要元数据,不信任用户身份和额度 +5. 子站向主站发起内部 authorize 请求 +6. 主站校验 API Key、用户状态、额度、限流、风控 +7. 主站为本次请求预冻结额度 +8. 主站确认子站是否有可用账号租约 +9. 主站返回 request_id、reservation_id、lease_id、account_id、授权上限 +10. 子站校验授权结果与自身租约一致 +11. 子站使用租约内账号调用上游 +12. 子站把模型响应直接返回给用户 +13. 子站先在本地持久化请求日志和用量 +14. 子站批量或实时向主站同步日志和用量 +15. 主站按 request_id 幂等入库 +16. 主站根据实际用量结算额度,多退少补 +``` + +--- + +### 6.3 请求模式选择 + +#### 模式一:子站数据面直连,主站控制面授权 + +```text +用户 → 子站 → 上游 +子站 → 主站:授权、预冻结、日志同步、心跳 +``` + +优点: + +- 主站不承载模型请求和流式响应; +- 能直接降低主服务器带宽压力; +- 子站承担实际请求流量; +- 用户 API 兼容性较好,不强制用户先调用 ticket 接口; +- 子站可以按地域、带宽、上游连通性横向扩容。 + +缺点: + +- 子站地址会暴露; +- 子站必须严格校验主站授权结果; +- 子站必须本地持久化未同步日志; +- 子站与主站之间要处理授权延迟和主站不可用问题。 + +适合: + +- 当前主站带宽受限场景; +- 需要真实分摊流量的多实例部署; +- 大量 SSE、WebSocket、长响应请求; +- 多地域边缘节点。 + +--- + +#### 模式二:主站签发 ticket,用户直连子站 + +```text +用户 → 主站获取 ticket +用户 → 子站携带 ticket 请求 +子站 → 上游 +子站 → 主站同步日志 +``` + +优点: + +- 主站压力较小; +- 子站承担实际请求流量; +- 扩展性更好; +- 适合多地域节点。 + +缺点: + +- 子站地址会暴露; +- 必须做好 ticket 校验; +- 必须防止用户绕过主站; +- 跨域和鉴权更复杂。 + +适合: + +- 请求量较大; +- 子站数量较多; +- 需要边缘节点分流。 + +--- + +#### 模式三:所有请求先到主站,由主站代理到子站 + +```text +用户 → 主站 → 子站 → 上游 +``` + +优点: + +- 用户只接触主站; +- 子站不暴露; +- 鉴权集中; +- 兼容旧客户端; +- 适合调试和灰度验证。 + +缺点: + +- 主站仍然承载全部请求和响应流量; +- 流式响应会长期占用主站连接; +- 主站带宽瓶颈不会被解决; +- 多一个转发链路,延迟更高。 + +适合: + +- 管理后台调试; +- 小流量灰度; +- 子站故障时的应急回退; +- 对子站直连暂时不可用的兼容场景。 + +不建议作为第一阶段主路径。 + +--- + +#### 推荐方式 + +第一阶段主路径应该使用: + +```text +用户 → 子站 → 上游 +子站 → 主站:authorize / usage / heartbeat +``` + +稳定后再增强为: + +```text +主站签发一次性 ticket 或短期授权令牌,子站本地校验后执行请求 +``` + +--- + +## 7. 请求授权设计 + +请求授权用于证明该请求已经经过主站鉴权、额度检查、限流检查和账号租约检查。 + +第一阶段可以不强制用户先向主站获取 ticket。更实用的方式是:用户直接请求子站,子站在请求开始前向主站申请一次内部授权,主站返回本次请求的授权上下文。 + +```http +POST /api/internal/requests/authorize +``` + +请求示例: + +```json +{ + "subsite_id": "site_001", + "api_key": "sk-xxx", + "model": "claude-sonnet", + "request_type": "stream", + "estimated_tokens": 200000, + "client_request_id": "client_req_optional", + "timestamp": "2026-05-09T10:00:00Z", + "nonce": "random_string" +} +``` + +响应示例: + +```json +{ + "request_id": "req_123", + "reservation_id": "rsv_123", + "subsite_id": "site_001", + "user_id": "user_001", + "api_key_id": "key_001", + "account_id": "acc_001", + "lease_id": "lease_001", + "provider": "claude", + "model": "claude-sonnet", + "max_tokens": 200000, + "expires_at": "2026-05-09T10:05:00Z" +} +``` + +子站必须校验: + +```text +响应签名是否来自主站 +响应中的 subsite_id 是否等于当前子站 +lease_id 是否仍在本地有效租约内 +account_id 是否属于当前子站 +model 是否匹配用户请求 +expires_at 是否未过期 +max_tokens 是否未被本地请求超出 +``` + +该模式的优势是用户侧协议变化小,缺点是每次请求开始前子站必须访问主站。为了避免主站故障时产生免费调用,主站不可用时默认拒绝新请求,只允许明确配置的短时降级策略。 + +--- + +### 7.1 一次性 request_ticket 模式 + +示例: + +```json +{ + "ticket_id": "tkt_123", + "request_id": "req_123", + "user_id": "user_001", + "api_key_id": "key_001", + "subsite_id": "site_001", + "account_id": "acc_001", + "lease_id": "lease_001", + "model": "claude-sonnet", + "max_tokens": 200000, + "expires_at": "2026-05-09T10:10:00Z", + "nonce": "random_string" +} +``` + +ticket 需要签名,例如: + +```text +signature = HMAC_SHA256(master_secret, ticket_payload) +``` + +或者使用 JWT/JWS。 + +子站校验: + +```text +ticket 是否有效 +ticket 是否过期 +ticket 是否属于当前子站 +ticket 是否绑定当前模型 +ticket 是否绑定当前 request_id +ticket 是否已经使用过 +``` + +建议 ticket: + +- 有效期短,例如 1-5 分钟; +- 一次性使用; +- 绑定子站; +- 绑定用户; +- 绑定模型; +- 绑定 request_id; +- 绑定账号租约; +- 防重放。 + +--- + +## 8. 账号租约机制 + +### 8.1 租约目的 + +账号租约用于确保: + +```text +同一个账号同一时间只能被一个子站使用 +``` + +同时便于主站回收和调度账号。 + +--- + +### 8.2 租约字段 + +```text +account_leases +- id +- lease_id +- account_id +- subsite_id +- status +- max_concurrency +- current_concurrency +- max_tokens +- used_tokens +- max_requests +- used_requests +- assigned_at +- expires_at +- renewed_at +- released_at +``` + +--- + +### 8.3 租约状态 + +```text +active 使用中 +renewing 续租中 +draining 排空中 +released 已释放 +expired 已过期 +revoked 被主站强制撤销 +``` + +--- + +### 8.4 分配流程 + +```text +1. 主站检查账号是否 idle +2. 主站检查账号健康状态 +3. 主站检查账号是否支持目标模型 +4. 主站检查子站是否可用 +5. 主站创建租约 +6. 主站将账号 owner_subsite_id 设置为目标子站 +7. 主站将账号状态设置为 assigned +8. 子站收到账号凭证或调用授权 +9. 子站开始使用账号 +``` + +--- + +### 8.5 回收流程 + +不能直接强制回收账号,推荐使用 draining 排空机制。 + +```text +1. 主站将账号状态设置为 draining +2. 主站通知子站停止给该账号分配新请求 +3. 子站等待已有请求完成 +4. 子站上报账号已释放 +5. 主站将租约状态设置为 released +6. 主站将账号状态设置为 idle +7. 主站可重新分配给其他子站 +``` + +如果子站长时间不响应: + +```text +1. 主站标记子站异常 +2. 账号进入 checking 或 cooldown +3. 暂时不要立即分配给其他子站 +4. 等待确认账号状态后再重新调度 +``` + +--- + +### 8.6 第一阶段最小租约策略 + +第一阶段不要一开始就实现复杂自动调度,可以先采用“手动分配 + 短租约 + 子站续租”的方式。 + +推荐规则: + +```text +租约有效期:10-30 分钟 +续租窗口:过期前 1-5 分钟 +子站心跳间隔:10-30 秒 +心跳超时:连续 3-5 次失败后标记子站异常 +账号回收:先 draining,再 released +异常子站账号:先 checking/cooldown,不立即分给其他子站 +``` + +数据库层必须保证: + +```text +同一个 account_id 同一时间只能有一个 active/renewing/draining 租约 +upstream_accounts.owner_subsite_id 必须与 active lease 的 subsite_id 一致 +租约状态变更必须在事务内完成 +租约续期必须校验当前租约仍归属当前子站 +``` + +子站本地也要维护租约缓存,但只能作为执行缓存,不能成为权威来源。主站一旦撤销租约,子站必须停止给该账号分配新请求。 + +--- + +## 9. 用户额度管理 + +### 9.1 额度以主站为准 + +子站只能上报实际使用量,不能直接修改用户最终额度。 + +--- + +### 9.2 预冻结 + 结算 + +推荐额度流程: + +```text +请求开始前:主站预冻结额度 +请求完成后:主站按实际用量结算 +请求失败:释放冻结额度或按规则扣减 +请求超时:定时任务处理冻结记录 +``` + +在子站数据面直连模式下,预冻结必须发生在子站调用上游之前: + +```text +1. 子站收到用户请求 +2. 子站向主站申请 authorize +3. 主站按模型、max_tokens、用户套餐估算冻结额度 +4. 冻结成功后主站返回 reservation_id +5. 子站才能调用上游 +6. 子站上报实际 token 和状态 +7. 主站按实际用量结算 reservation +``` + +主站不可用时,默认不允许新请求继续调用上游。否则子站可能产生无法结算的真实上游成本。 + +--- + +### 9.3 额度字段 + +```text +user_quotas +- user_id +- total_quota +- used_quota +- frozen_quota +- available_quota +- updated_at +``` + +其中: + +```text +available_quota = total_quota - used_quota - frozen_quota +``` + +--- + +### 9.4 冻结记录 + +```text +quota_reservations +- id +- reservation_id +- request_id +- user_id +- estimated_tokens +- actual_tokens +- status +- expires_at +- created_at +- settled_at +``` + +状态: + +```text +frozen 已冻结 +settled 已结算 +released 已释放 +expired 已过期 +failed 请求失败 +``` + +--- + +## 10. 日志同步设计 + +### 10.1 日志上报内容 + +子站向主站上报: + +```json +{ + "request_id": "req_123", + "subsite_id": "site_001", + "account_id": "acc_001", + "lease_id": "lease_001", + "user_id": "user_001", + "api_key_id": "key_001", + "provider": "claude", + "model": "claude-sonnet", + "status": "success", + "prompt_tokens": 1000, + "completion_tokens": 2000, + "total_tokens": 3000, + "latency_ms": 5200, + "error_code": null, + "created_at": "2026-05-09T10:00:00Z", + "completed_at": "2026-05-09T10:00:05Z" +} +``` + +--- + +### 10.2 幂等要求 + +每个请求必须有全局唯一 `request_id`。 + +主站数据库需要设置唯一约束: + +```text +UNIQUE(request_id) +``` + +重复上报时: + +- 不重复插入日志; +- 不重复扣费; +- 不重复结算额度。 + +--- + +### 10.3 批量上报 + +推荐子站批量同步: + +```http +POST /api/internal/usage/batch +``` + +请求示例: + +```json +{ + "subsite_id": "site_001", + "batch_id": "batch_20260509_001", + "records": [ + { + "request_id": "req_001", + "user_id": "user_123", + "api_key_id": "key_456", + "model": "claude-sonnet", + "prompt_tokens": 1000, + "completion_tokens": 2000, + "total_tokens": 3000, + "status": "success", + "latency_ms": 4200, + "created_at": "2026-05-09T10:00:00Z" + } + ] +} +``` + +响应示例: + +```json +{ + "accepted": 100, + "duplicated": 3, + "failed": 0 +} +``` + +--- + +### 10.4 本地缓冲和补偿 + +子站必须本地保存未同步日志。 + +本地表: + +```text +local_request_logs +- request_id +- payload +- sync_status +- retry_count +- last_retry_at +- created_at +``` + +同步状态: + +```text +pending 待同步 +syncing 同步中 +synced 已同步 +failed 同步失败 +``` + +如果主站不可用: + +```text +1. 子站继续本地保存日志 +2. 定时重试同步 +3. 主站恢复后补传 +4. 主站按 request_id 幂等处理 +``` + +--- + +## 11. 主站与子站认证 + +### 11.1 基础认证 + +每个子站拥有: + +```text +subsite_id +subsite_secret +``` + +请求主站时需要签名。 + +签名内容: + +```text +timestamp +nonce +body_hash +``` + +签名算法: + +```text +signature = HMAC_SHA256(subsite_secret, timestamp + nonce + body_hash) +``` + +主站校验: + +- subsite_id 是否存在; +- signature 是否正确; +- timestamp 是否过期; +- nonce 是否重复; +- 子站状态是否正常; +- IP 是否在白名单内。 + +--- + +### 11.2 推荐增强安全 + +更安全的方式: + +```text +mTLS 双向 TLS +``` + +每个子站拥有自己的客户端证书。 + +优点: + +- 防止伪造子站; +- 防止中间人攻击; +- 便于吊销子站; +- 适合主站-子站强信任通信。 + +--- + +## 12. 凭证下发风险与建议 + +### 12.1 模式 A:真实凭证下发到子站 + +```text +主站把真实上游账号凭证下发给子站 +子站直接调用上游 +``` + +优点: + +- 性能好; +- 延迟低; +- 主站压力小; +- 子站可独立执行请求。 + +缺点: + +- 子站被攻破后,上游账号可能泄露; +- 子站理论上可以绕过主站调用; +- 主站只能依赖子站上报统计; +- 凭证安全要求高。 + +如果使用此模式,必须做到: + +- 凭证加密传输; +- 子站尽量不落盘; +- 如需落盘必须加密; +- 租约短期有效; +- 主站可随时撤销; +- 子站隔离部署; +- 定期轮换凭证; +- 异常时立即回收账号; +- 记录所有凭证下发行为。 + +--- + +### 12.2 模式 B:短期临时凭证 + +```text +主站不长期下发真实凭证 +只下发短期临时调用授权 +``` + +临时凭证可限制: + +- 只能由指定子站使用; +- 只能调用指定模型; +- 只能使用指定账号; +- 有效期短; +- 最大并发; +- 最大 token; +- 最大请求数。 + +这是比较推荐的中长期方案。 + +--- + +### 12.3 模式 C:子站不接触真实凭证 + +```text +子站接收请求 +主站持有真实凭证 +主站调用上游 +``` + +优点: + +- 最安全; +- 子站被攻破损失小; +- 主站完全掌握调用; +- 计费更准确。 + +缺点: + +- 主站压力最大; +- 延迟更高; +- 子站分流意义降低。 + +--- + +### 12.4 推荐选择 + +如果目标是让子站真正分担请求压力: + +```text +第一阶段:可以使用模式 A,但必须配合租约、加密、审计和隔离 +第二阶段:升级到模式 B,使用短期临时凭证 +高安全场景:使用模式 C +``` + +--- + +## 13. 数据库设计建议 + +### 13.1 子站表 + +```text +subsites +- id +- name +- domain +- region +- status +- secret_hash +- public_key +- max_qps +- max_concurrency +- current_qps +- current_concurrency +- health_score +- version +- last_heartbeat_at +- created_at +- updated_at +``` + +--- + +### 13.2 上游账号表 + +```text +upstream_accounts +- id +- provider +- account_name +- encrypted_credentials +- status +- supported_models +- max_concurrency +- current_concurrency +- owner_subsite_id +- lease_id +- lease_expires_at +- health_score +- remaining_quota +- last_error_code +- last_used_at +- created_at +- updated_at +``` + +--- + +### 13.3 账号租约表 + +```text +account_leases +- id +- lease_id +- account_id +- subsite_id +- status +- max_concurrency +- current_concurrency +- max_tokens +- used_tokens +- max_requests +- used_requests +- assigned_at +- expires_at +- renewed_at +- released_at +- created_at +- updated_at +``` + +--- + +### 13.4 请求日志表 + +```text +requests +- id +- request_id +- user_id +- api_key_id +- subsite_id +- account_id +- lease_id +- provider +- model +- status +- prompt_tokens +- completion_tokens +- total_tokens +- cost +- latency_ms +- error_code +- error_message +- created_at +- completed_at +``` + +--- + +### 13.5 用户额度表 + +```text +user_quotas +- user_id +- total_quota +- used_quota +- frozen_quota +- available_quota +- updated_at +``` + +--- + +### 13.6 额度冻结表 + +```text +quota_reservations +- id +- reservation_id +- request_id +- user_id +- estimated_tokens +- actual_tokens +- status +- expires_at +- created_at +- settled_at +``` + +--- + +### 13.7 子站心跳表 + +```text +subsite_heartbeats +- id +- subsite_id +- active_requests +- qps +- error_rate +- avg_latency_ms +- assigned_accounts +- pending_sync_logs +- cpu_usage +- memory_usage +- disk_usage +- version +- created_at +``` + +--- + +## 14. 调度策略建议 + +### 14.1 按子站负载调度 + +考虑指标: + +- 当前并发; +- QPS; +- CPU; +- 内存; +- 平均延迟; +- 错误率; +- 请求队列长度。 + +负载较低的子站优先分配更多账号。 + +--- + +### 14.2 按地域调度 + +根据用户 IP 或区域选择距离更近的子站。 + +例如: + +```text +亚洲用户 → 亚洲节点 +欧洲用户 → 欧洲节点 +美国用户 → 美国节点 +``` + +--- + +### 14.3 按账号健康度调度 + +账号健康评分高的优先使用。 + +健康评分受以下因素影响: + +- 429 频率; +- 403 频率; +- 超时率; +- 成功率; +- 平均延迟; +- 剩余额度; +- 最近错误。 + +异常账号自动进入: + +```text +cooldown +checking +disabled +``` + +--- + +### 14.4 按模型能力调度 + +账号不一定支持所有模型。 + +账号表需要记录: + +```text +supported_models +``` + +调度时必须匹配用户请求模型。 + +--- + +### 14.5 按子站能力调度 + +子站也可以设置能力标签: + +```text +site_001: claude, openai +site_002: gemini +site_003: claude +``` + +调度时只选择支持目标 provider/model 的子站。 + +--- + +## 15. 风险与应对 + +### 15.1 上游服务条款风险 + +如果将订阅账号通过平台分发给多个用户或子站,可能违反上游平台服务条款。 + +风险: + +- 账号被封; +- 账号限流; +- 订阅取消; +- 支付风控; +- IP 风控; +- 组织封禁。 + +建议: + +- 确认上游服务条款; +- 避免异常高并发; +- 控制账号调用行为; +- 做账号冷却; +- 保留合规审计; +- 不要过度共享个人订阅账号。 + +--- + +### 15.2 子站凭证泄露风险 + +风险: + +- 子站被攻破; +- 上游账号 Token/Cookie/API Key 泄露; +- 攻击者绕过主站调用上游。 + +建议: + +- 凭证短期化; +- 凭证加密; +- 尽量不落盘; +- 子站隔离部署; +- 定期轮换; +- 异常自动撤销; +- 主站保留凭证分发审计。 + +--- + +### 15.3 子站日志不同步 + +风险: + +- 主站额度不准; +- 用户用量缺失; +- 请求记录缺失; +- 计费异常。 + +建议: + +- 子站本地持久化日志; +- 批量同步; +- 失败重试; +- 幂等处理; +- 定期对账; +- 长时间不上报则暂停子站; +- 使用预冻结额度减少超用风险。 + +--- + +### 15.4 账号回收冲突 + +风险: + +- 主站回收账号时,子站仍有请求在跑; +- 同一个账号短时间出现在两个子站; +- 上游账号并发异常。 + +建议: + +- 使用 draining 状态; +- 子站停止新请求; +- 等待已有请求完成; +- 子站确认释放; +- 超时后进入 checking/cooldown; +- 不要强制立即分配给其他子站。 + +--- + +### 15.5 主站单点故障 + +风险: + +- 用户无法登录; +- 请求无法调度; +- 子站无法获取新租约; +- 日志无法同步; +- 额度无法结算。 + +建议: + +- 主站无状态化部署; +- 数据库高可用; +- Redis 高可用; +- 子站保留短期租约; +- 主站不可用时子站进入保护模式; +- 默认拒绝新请求,避免产生无法结算的上游成本; +- 如明确启用降级,只允许已有租约和已授权请求继续执行; +- 禁止新租约、新用户、新大额请求; +- 主站恢复后必须先补传日志并对账,再恢复正常调度。 + +--- + +### 15.6 用户绕过主站直连子站 + +风险: + +- 用户绕过额度检查; +- 用户绕过限流; +- 用户直接刷子站。 + +建议: + +- 子站所有请求必须经过主站 authorize 或携带主站签发 ticket; +- authorize 响应和 ticket 都必须短期有效; +- ticket 一次性使用; +- 授权上下文必须绑定用户、模型、子站、request_id、lease_id 和 account_id; +- 子站拒绝无授权请求; +- 子站限制来源; +- 子站接口签名校验。 + +--- + +### 15.7 用户隐私风险 + +请求日志可能包含敏感信息: + +- prompt; +- response; +- 代码; +- 密钥; +- 文件内容; +- 商业数据; +- 个人隐私; +- 医疗、金融、法律信息。 + +建议: + +- 默认只同步元数据; +- 不记录完整 prompt/response; +- 如需记录,必须加密; +- 设置日志保留周期; +- 支持用户删除; +- 对敏感字段脱敏; +- 不记录 Authorization、Cookie、上游 Token。 + +推荐同步: + +```json +{ + "request_id": "req_xxx", + "user_id": "user_xxx", + "model": "claude-sonnet", + "prompt_tokens": 1000, + "completion_tokens": 2000, + "total_tokens": 3000, + "status": "success", + "latency_ms": 8520 +} +``` + +不推荐同步: + +```json +{ + "authorization": "Bearer xxx", + "cookie": "xxx", + "full_prompt": "...", + "full_response": "..." +} +``` + +--- + +## 16. 后台控制开关 + +主站后台建议提供以下控制能力: + +```text +新增子站 +编辑子站 public_url / region / capabilities +查看子站推荐入口 URL +生成子站 subsite_secret +生成一次性 join_token +确认子站接入 +激活子站 +暂停某个子站 +恢复某个子站 +强制回收某个账号 +暂停某个账号 +禁用某个账号 +账号进入冷却 +设置子站最大 QPS +设置子站最大并发 +设置子站最大账号数 +设置账号最大并发 +设置模型级限流 +设置用户级限流 +设置用户全局封禁 +设置子站维护模式 +清空子站租约 +查看子站未同步日志 +触发子站补传日志 +触发账号健康检查 +查看子站 agent 版本 +触发子站 agent 配置刷新 +``` + +--- + +## 17. 分阶段落地建议 + +### 17.1 第一阶段:控制面和数据面拆分 + +目标: + +```text +主站统一管理账号 +主站管理用户和额度 +主站管理子站 +主站面板展示可用子站 URL +主站手动或半自动分配账号给子站 +一个账号同一时间只属于一个子站 +子站只部署精简 subsite-agent +用户模型请求直连子站 +子站承担模型请求和流式响应带宽 +主站只处理 authorize / usage / heartbeat 等控制面请求 +子站同步日志给主站 +``` + +第一阶段可以先不做复杂自动调度。 + +重点完成: + +- 子站注册; +- 子站心跳; +- 精简 subsite-agent 二进制; +- 主站后台子站管理页面; +- 主站展示可用子站 URL; +- 手动或半自动账号分配; +- 子站请求入口; +- 子站向主站请求 authorize; +- 主站校验 API Key、用户状态、额度和限流; +- 主站预冻结额度; +- 子站本地执行上游调用; +- 子站直接向用户返回响应; +- 子站本地持久化请求日志; +- 子站批量请求日志上报; +- 主站按 request_id 幂等入库和结算; +- 账号不能重复分配; +- 子站本地日志缓存。 + +第一阶段暂不建议做: + +- 全自动账号调度; +- 复杂地域路由; +- mTLS; +- 消息队列; +- 多主站高可用; +- 统一域名无感分流; +- 用户先取 ticket 再请求子站的双跳协议。 + +这些能力可以后续补,但第一阶段必须先把主站带宽从模型数据面中移除。 + +--- + +### 17.2 第二阶段:加入完整租约和自动调度 + +加入: + +- 账号租约; +- 租约过期; +- 租约续期; +- 租约释放; +- draining 排空; +- 自动账号调度; +- 子站健康评分; +- 账号健康评分; +- 用户额度预冻结; +- request_ticket; +- 日志幂等上报。 + +第二阶段的目标是把第一阶段的手动/半自动分配升级为自动调度,同时保持一个账号同一时间只属于一个子站。 + +--- + +### 17.3 第三阶段:增强高可用和风控 + +加入: + +- 全局限流; +- 全局并发; +- mTLS; +- 子站异常检测; +- 账号异常检测; +- 自动冷却; +- 自动扩缩容; +- 对账系统; +- 主站高可用; +- Redis/数据库高可用; +- 消息队列; +- 灰度发布; +- 多地域调度。 + +--- + +## 18. 最推荐的最终流程 + +最终推荐流程: + +```text +1. 用户登录主站 +2. 用户在主站创建 API Key +3. 用户选择或获取可用子站入口 +4. 用户携带 API Key 直接请求子站 +5. 子站向主站发起 authorize 控制面请求 +6. 主站校验 API Key、用户状态、额度、并发和风控 +7. 主站确认子站状态和账号租约 +8. 主站预冻结额度 +9. 主站返回 request_id、reservation_id、lease_id、account_id 和授权上限 +10. 子站校验授权结果属于当前子站和当前租约 +11. 子站使用租约内账号调用上游 +12. 子站将上游响应直接返回给用户 +13. 子站本地保存请求日志和用量 +14. 子站批量或实时上报日志给主站 +15. 主站按 request_id 幂等入库 +16. 主站按实际 token 结算额度 +17. 主站更新账号、子站、用户统计 +18. 主站按心跳、错误率和用量决定续租、回收或冷却 +``` + +--- + +## 19. 开发实施总纲 + +正式开发时建议按“主站能力、子站 agent、通信协议、数据存储、运维控制”五条线推进。 + +### 19.1 最终产品形态 + +```text +主站 sub2api-server +- 部署完整项目; +- 提供用户前台、管理后台、支付、账号管理、额度、日志、统计; +- 管理所有子站、账号租约、请求授权、用量结算; +- 不承载常态模型请求和流式响应。 + +子站 sub2api-subsite-agent +- 部署轻量代理程序; +- 不提供用户系统和后台面板; +- 负责接收用户模型请求、向主站 authorize、调用上游、返回响应; +- 本地缓存租约和日志; +- 向主站上报 usage、heartbeat、release、draining 状态。 + +用户客户端 +- 可以是第三方 API 客户端,也可以是未来自有客户端; +- 只保存用户自己的 API Key 和子站 URL; +- 不持有上游账号凭证、主站密钥、子站密钥。 +``` + +### 19.2 第一阶段开发范围 + +第一阶段目标是先把主站带宽从模型数据面中移除,不追求自动化调度完整度。 + +必须完成: + +```text +1. 主站新增子站管理模型 +2. 主站后台可创建、编辑、暂停、恢复子站 +3. 主站后台展示可用子站 URL +4. 主站支持手动/半自动把账号分配给子站 +5. 主站提供 authorize API +6. 主站提供 usage batch API +7. 主站提供 heartbeat API +8. 主站支持额度预冻结和最终结算 +9. 主站按 request_id + api_key_id 幂等处理日志和计费 +10. 子站 agent 提供模型请求入口 +11. 子站 agent 请求前调用主站 authorize +12. 子站 agent 校验授权上下文 +13. 子站 agent 使用当前租约账号调用上游 +14. 子站 agent 直接把响应返回给用户 +15. 子站 agent 本地持久化未同步日志 +16. 子站 agent 批量上报 usage +17. 子站 agent 定时 heartbeat +18. 子站异常时主站可暂停该子站 +``` + +第一阶段暂不做: + +```text +统一 API 域名无感分流 +mTLS +消息队列 +自动扩缩容 +复杂地域路由 +全自动账号调度 +多主站高可用 +用户本地代理执行节点 +``` + +### 19.3 新子站接入流程 + +后期随时增加服务器时,推荐把子站接入设计成“主站预创建 + 子站 agent 带密钥接入 + 管理员激活”的受控流程。第一版不建议开放完全自助注册。 + +标准接入流程: + +```text +1. 管理员在主站后台新增子站 +2. 主站生成 subsite_id 和 subsite_secret +3. 主站记录 public_url、region、capabilities、max_qps、max_concurrency +4. 子站状态为 pending +5. 管理员在新服务器部署 sub2api-subsite-agent +6. agent 配置 master_url、subsite_id、subsite_secret、public_url +7. agent 启动后向主站发送 heartbeat +8. 主站校验签名并记录版本、public_url、心跳时间和本地队列深度 +9. 管理员在主站后台确认并激活子站 +10. 主站将子站状态更新为 active +11. 管理员为该子站创建账号租约 +12. 主站面板展示该子站 URL +13. 子站开始承接用户模型请求 +``` + +子站 agent 配置示例: + +```yaml +subsite: + id: site_sg_02 + public_url: https://sg-02-api.example.com + region: sg + capabilities: + - anthropic + - openai + +master: + base_url: https://master.example.com + subsite_secret: ${SUBSITE_SECRET} + +local: + listen_addr: 0.0.0.0:8080 + queue_path: ./data/usage_queue.db +``` + +安全要求: + +```text +subsite_secret 只显示一次或只允许重置 +子站首次 heartbeat 必须签名 +pending 子站不能拿账号租约 +只有 active 子站才能 authorize 成功 +disabled / unhealthy / maintenance 子站不能获取新授权 +``` + +新增子站时不要立即从旧子站强行抢账号。账号迁移必须遵循: + +```text +1. 新子站 active +2. 主站优先把 idle 账号分配给新子站 +3. 如需迁移旧账号,旧子站账号先进入 draining +4. 旧子站停止为该账号分配新请求 +5. 已有请求完成 +6. 旧子站 release 租约 +7. 主站再把账号分配给新子站 +``` + +后续可以升级为一次性接入令牌: + +```text +1. 主站生成 join_token +2. join_token 有效期 10 分钟,只显示一次 +3. 新 agent 启动时携带 join_token 调用 register +4. 主站验证 join_token 后绑定 subsite_id +5. agent 写入本地配置或接收子站密钥 +6. 管理员确认激活 +``` + +这种模式适合频繁扩容,但第一版建议先使用手动预创建,降低接入风险。 + +### 19.4 推荐模块拆分 + +主站新增模块: + +```text +SubsiteService +- 子站注册、编辑、暂停、恢复; +- 子站能力、区域、public_url、版本管理; +- 子站状态和健康评分维护。 + +SubsiteAuthService +- 子站 HMAC 签名校验; +- timestamp / nonce / body_hash 校验; +- 防重放。 + +RequestAuthorizeService +- 校验用户 API Key; +- 校验用户状态、额度、限流、风控; +- 选择当前子站可用租约; +- 创建 request_id; +- 创建 quota reservation; +- 返回授权上下文。 + +AccountLeaseService +- 创建租约; +- 续租; +- draining; +- released / expired / revoked 状态变更; +- 保证一个账号同一时间只属于一个子站。 + +UsageIngestService +- 接收子站 usage batch; +- 按 request_id 幂等入库; +- 结算额度; +- 更新账号、用户、子站统计。 +``` + +子站 agent 模块: + +```text +ProxyHandler +- 提供 OpenAI / Claude / Gemini 兼容入口; +- 支持普通 HTTP、SSE、WebSocket; +- 只做必要请求解析,不做最终身份信任。 + +MasterClient +- 调用 authorize; +- 调用 usage batch; +- 调用 heartbeat; +- 拉取租约/配置; +- 使用 HMAC 签名。 + +LeaseStore +- 本地缓存当前子站租约; +- 处理租约过期、撤销、draining; +- 禁止使用不属于当前子站的账号。 + +UpstreamExecutor +- 使用租约账号调用上游; +- 复用现有项目中的上游请求构造、SSE 处理、错误映射能力; +- 不包含管理后台和主站业务。 + +UsageQueue +- 请求完成后先本地落盘; +- 支持 pending / syncing / synced / failed; +- 支持重试和补传。 + +HeartbeatReporter +- 定期上报 active_requests、qps、error_rate、pending_sync_logs、版本等信息。 +``` + +### 19.5 第一阶段接口清单 + +主站控制面接口(当前代码实际路径): + +```http +POST /api/internal/subsites/heartbeat +GET /api/internal/subsites/config +POST /api/internal/requests/authorize +POST /api/internal/requests/cancel +POST /api/internal/usage/batch +POST /api/internal/leases/renew +POST /api/internal/leases/release +``` + +子站数据面接口建议: + +```http +POST /v1/messages +POST /v1/chat/completions +POST /v1/responses +GET /v1/responses (Responses WebSocket) +POST /v1/images/generations +POST /v1/images/edits +POST /v1beta/models/*path +GET /v1beta/models/*path +GET /healthz +GET /readyz +``` + +后台管理接口(当前代码实际路径): + +```http +GET /api/admin/subsites +POST /api/admin/subsites +PATCH /api/admin/subsites/{id} +POST /api/admin/subsites/{id}/pause +POST /api/admin/subsites/{id}/resume +GET /api/admin/subsites/{id}/leases +POST /api/admin/subsites/{id}/activate +POST /api/admin/subsites/{id}/leases +POST /api/admin/subsites/{id}/leases/{lease_id}/drain +POST /api/admin/subsites/{id}/leases/{lease_id}/release +POST /api/admin/subsites/{id}/leases/{lease_id}/renew +``` + +### 19.6 数据库第一阶段最小表 + +第一阶段至少需要: + +```text +subsites +- id +- name +- public_url +- region +- capabilities +- status +- join_token_hash +- join_token_expires_at +- secret_hash +- max_qps +- max_concurrency +- version +- last_heartbeat_at +- health_score +- created_at +- updated_at + +account_leases +- id +- lease_id +- account_id +- subsite_id +- status +- max_concurrency +- max_requests +- max_tokens +- used_requests +- used_tokens +- assigned_at +- expires_at +- renewed_at +- released_at +- created_at +- updated_at + +quota_reservations +- id +- reservation_id +- request_id +- user_id +- api_key_id +- estimated_cost +- actual_cost +- status +- expires_at +- created_at +- settled_at + +subsite_heartbeats +- id +- subsite_id +- active_requests +- qps +- error_rate +- avg_latency_ms +- pending_sync_logs +- cpu_usage +- memory_usage +- version +- created_at +``` + +关键约束: + +```text +account_leases: 同一个 account_id 只能存在一个 active/renewing/draining 租约 +usage_logs: request_id + api_key_id 唯一 +usage_billing_dedup: request_id + api_key_id 唯一 +quota_reservations: request_id 唯一 +subsites: public_url 唯一 +``` + +### 19.7 用户本地客户端安全边界 + +未来可以做自有客户端,但它只能是“入口选择客户端”,不能是“请求执行节点”。 + +允许客户端做: + +```text +登录主站 +获取可用子站 URL +测速和选择最近子站 +保存用户自己的 API Key +把模型请求发送到子站 +在子站失败时切换到其他子站 +展示用量和余额 +``` + +禁止客户端做: + +```text +保存上游账号 Cookie / Token / API Key +保存 subsite_secret +保存 master_secret +持有账号租约真实凭证 +直接调用上游 AI 服务 +本地决定额度、计费、账号归属 +作为子站 agent 部署在用户机器上 +``` + +原因: + +```text +用户本地环境默认不可信。 +客户端可以被反编译、调试、抓包和篡改。 +任何下发到客户端的真实秘密,都应视为已经泄露。 +``` + +安全原则: + +```text +子站 agent 只能部署在你控制的服务器上。 +用户本地只能部署普通客户端或入口选择器。 +真正的上游凭证、主站密钥、子站密钥永远不能下发到用户本地。 +``` + +### 19.8 开发顺序建议 + +建议按以下顺序开发: + +```text +1. 新增 subsites / account_leases / quota_reservations 迁移 +2. 实现主站 SubsiteService 和后台子站管理 +3. 实现子站 pending / active / maintenance / unhealthy / disabled 状态机 +4. 实现主站子站签名校验 +5. 实现新子站预创建和激活流程 +6. 实现主站 authorize API +7. 实现主站 usage batch API +8. 实现主站 heartbeat API +9. 新增 sub2api-subsite-agent 入口 +10. 子站 agent 实现 MasterClient +11. 子站 agent 实现本地 UsageQueue +12. 子站 agent 复用现有上游调用能力跑通单模型请求 +13. 跑通用户 -> 子站 -> 上游 -> 子站 -> 用户 +14. 跑通子站 usage 上报和主站结算 +15. 加入租约续期、release、draining +16. 后台展示子站状态、租约、未同步日志 +17. 做异常场景测试:主站不可用、子站断线、重复 usage、账号回收、流式中断 +``` + +第一版验收标准: + +```text +主站带宽不再承载模型响应 +用户可以通过主站展示的子站 URL 发起请求 +子站没有主站后台和用户系统 +无 authorize 不调用上游 +usage 重复上报不重复扣费 +子站断线不丢用量日志 +账号不会同时分配给两个子站 +主站可以暂停子站并阻止新请求 +``` + +### 19.9 当前生产测试步骤 + +以当前仓库实现为准,第一轮生产测试建议只接入受控流量。 + +主站侧: + +```text +1. 部署主站新版本,启动时自动执行 141_subsite_control_plane.sql。 +2. 进入后台“子站管理”,新建子站,填写 name、public_url、region、capabilities。 +3. 复制一次性显示的 subsite_secret。 +4. 子站保持 pending,先不要激活。 +``` + +子站侧 Docker Compose: + +```text +1. 在子站服务器复制 deploy/docker-compose.subsite-agent.yml 和 deploy/.env.subsite.example。 +2. 将 .env.subsite.example 复制为 .env.subsite。 +3. 填入 SUBSITE_ID、SUBSITE_PUBLIC_URL、SUBSITE_MASTER_URL、SUBSITE_MASTER_SECRET。 +4. 执行 docker compose --env-file .env.subsite -f docker-compose.subsite-agent.yml up -d --build。 +5. 访问 /healthz 和 /readyz,确认 agent 存活。 +6. 在主站后台确认 last_heartbeat_at 已更新。 +``` + +激活和租约: + +```text +1. 在主站后台激活子站。 +2. 为子站创建至少一个账号租约。 +3. 使用子站 public_url 发起测试请求,Authorization 仍使用主站 API Key。 +4. 确认响应由子站返回,主站只产生 authorize / usage / heartbeat 控制面流量。 +5. 确认 usage 最终进入主站日志和计费。 +``` + +必须验证: + +```text +1. 无 API Key 请求被拒绝。 +2. pending / maintenance 子站无法 authorize。 +3. 重复 usage 上报不会重复扣费。 +4. 临时停止主站后,子站 usage_queue SQLite 文件仍保留未同步记录。 +5. 主站恢复后,usage_queue 被批量上报并清空已确认记录。 +6. 主站暂停子站后,新请求被拒绝,已有流式请求自然结束。 +``` + +回滚方式: + +```text +1. 主站后台暂停子站。 +2. 停止子站 agent。 +3. 释放或排空该子站账号租约。 +4. 用户流量切回主站原入口或其他已验证子站。 +``` + +--- + +## 20. 总结 + +该方案整体可行,并且比普通多实例部署更安全、更可控。 + +核心设计应该坚持: + +```text +1. 主站是唯一权威数据源。 +2. 子站只部署精简代理程序,不部署完整面板。 +3. 子站只是数据面请求执行节点。 +4. 用户登录、前端、额度、日志查询全部在主站。 +5. 主站面板负责展示可用子站 URL 或统一入口配置。 +6. 一个账号同一时间只能分配给一个子站。 +7. 账号分配必须使用租约机制。 +8. 账号回收必须使用 draining 排空机制。 +9. 用户额度必须由主站预冻结和最终结算。 +10. 子站不能直接写主站数据库。 +11. 子站日志同步必须支持幂等、重试和补偿。 +12. 子站请求必须经过主站 authorize 或校验主站签发的 ticket。 +13. 上游真实凭证下发要短期、加密、可撤销。 +14. 主站需要全局限流、全局并发和风控能力。 +15. 主站和子站通信必须签名,最好支持 mTLS。 +16. 子站异常时,主站可以暂停、回收、降级和对账。 +``` + +一句话总结: + +> 推荐将主站设计为控制中心和权威数据中心,将子站设计为无用户体系的轻量代理程序;主站通过子站 URL 管理、账号租约、请求 authorize/ticket、额度预冻结、日志幂等同步和 draining 回收机制,实现安全可控的多子站动态账号调度。 + +--- diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index db65a3260..2a02956e2 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -479,6 +479,7 @@ export interface SystemSettings { enable_fingerprint_unification: boolean; enable_metadata_passthrough: boolean; enable_cch_signing: boolean; + openai_clean_relay_enabled: boolean; enable_anthropic_cache_ttl_1h_injection: boolean; web_search_emulation_enabled?: boolean; @@ -704,6 +705,7 @@ export interface UpdateSettingsRequest { enable_fingerprint_unification?: boolean; enable_metadata_passthrough?: boolean; enable_cch_signing?: boolean; + openai_clean_relay_enabled?: boolean; enable_anthropic_cache_ttl_1h_injection?: boolean; // Payment configuration payment_enabled?: boolean; diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 451beed76..adb2a1581 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3499,12 +3499,8 @@ const handleSubmit = async () => { // Handle API key if (editApiKey.value.trim()) { - // User provided a new API key newCredentials.api_key = editApiKey.value.trim() - } else if (currentCredentials.api_key) { - // Preserve existing api_key - newCredentials.api_key = currentCredentials.api_key - } else { + } else if (!props.account.credentials_status?.has_api_key) { appStore.showError(t('admin.accounts.apiKeyIsRequired')) return } @@ -3589,7 +3585,10 @@ const handleSubmit = async () => { return } - if (!currentCredentials.service_account_json && !currentCredentials.service_account) { + const hasExistingServiceAccountJson = + props.account.credentials_status?.has_service_account_json || + props.account.credentials_status?.has_service_account + if (!hasExistingServiceAccountJson) { appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index cf927eaf5..5ce5bd050 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5896,6 +5896,8 @@ export default { fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', metadataPassthrough: 'Metadata Passthrough', metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', + cleanRelay: 'Clean Relay Mode', + cleanRelayHint: 'Uses gateway-managed upstream installation/session/cache identifiers for OpenAI OAuth requests. First entry or account migration clears previous_response_id, encrypted reasoning, and old turn state.', cchSigning: 'CCH Signing', cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', anthropicCacheTTL1hInjection: 'Anthropic Cache TTL Injection', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index cf6e652b7..c21604594 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6054,6 +6054,8 @@ export default { fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', metadataPassthrough: 'Metadata 透传', metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', + cleanRelay: '洁净中继模式', + cleanRelayHint: '开启后,OpenAI OAuth 请求会使用中转站维护的上游 installation/session/cache 标识;首次进站或账号迁移时会清理 previous_response_id、加密 reasoning 和旧 turn state。', cchSigning: 'CCH 签名', cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 46d45f420..955f6c9bd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -953,6 +953,7 @@ export interface Account { account_level: AccountLevel type: AccountType credentials?: Record + credentials_status?: Record // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 719fbb56d..b9b33653b 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3416,6 +3416,21 @@ + +
+
+ +

+ {{ t("admin.settings.gatewayForwarding.cleanRelayHint") }} +

+
+ +
+
@@ -6824,6 +6839,7 @@ type SettingsForm = Omit< openai_advanced_scheduler_enabled: boolean; openai_free_account_repair_enabled: boolean; openai_free_account_repair_weekly_threshold_usd: number; + openai_clean_relay_enabled: boolean; }; const form = reactive({ @@ -7020,6 +7036,7 @@ const form = reactive({ enable_fingerprint_unification: true, enable_metadata_passthrough: false, enable_cch_signing: false, + openai_clean_relay_enabled: false, enable_anthropic_cache_ttl_1h_injection: false, // Balance & quota notification balance_low_notify_enabled: false, @@ -8211,6 +8228,7 @@ async function saveSettings() { enable_fingerprint_unification: form.enable_fingerprint_unification, enable_metadata_passthrough: form.enable_metadata_passthrough, enable_cch_signing: form.enable_cch_signing, + openai_clean_relay_enabled: form.openai_clean_relay_enabled, enable_anthropic_cache_ttl_1h_injection: form.enable_anthropic_cache_ttl_1h_injection, // Payment configuration From a731e8bc4deddd228df42851c245eb1feafef910 Mon Sep 17 00:00:00 2001 From: PIXEL-AI-API <281688875+PIXEL-AI-API@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:42:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20PR=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 升级 CI 校验的 Go patch 版本以通过 govulncheck - 修复 golangci-lint 报告的无效赋值和未检查类型断言 - 更新 API contract 与 scheduler snapshot 测试数据 - 将外部 TLS 指纹服务的 EOF/握手失败识别为外部服务不可用并跳过 验证: - golangci-lint run --timeout=30m - go test -tags=unit ./... - go test -tags=integration ./... - govulncheck ./... - pnpm build 提交前已检查暂存清单,未包含 backend/data、test/release、日志、pprof、tar 或构建产物。 --- .github/workflows/backend-ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- backend/go.mod | 2 +- .../tlsfingerprint/dialer_integration_test.go | 5 +++++ backend/internal/server/api_contract_test.go | 2 ++ .../openai_gateway_chat_completions_raw.go | 3 +-- .../openai_ws_forwarder_ingress_session_test.go | 11 +++++++++-- .../service/scheduler_snapshot_hydration_test.go | 2 ++ .../internal/service/usage_record_worker_pool.go | 16 ++++++++++++---- 10 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 15ff97fe0..fb4d0ce65 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -20,7 +20,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.3' + go version | grep -q 'go1.26.4' - name: Unit tests working-directory: backend run: make test-unit @@ -60,7 +60,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.3' + go version | grep -q 'go1.26.4' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80bc9850d..7d48131aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.3' + go version | grep -q 'go1.26.4' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ef8e59e54..e102b5f86 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.3' + go version | grep -q 'go1.26.4' - name: Run govulncheck working-directory: backend run: | diff --git a/backend/go.mod b/backend/go.mod index caf35ea4a..37f9919c2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.3 +go 1.26.4 require ( entgo.io/ent v0.14.5 diff --git a/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go b/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go index 38cddd0d9..7ac1d2e7d 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go +++ b/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go @@ -28,8 +28,13 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) { if strings.Contains(errStr, "certificate has expired") || strings.Contains(errStr, "certificate is not yet valid") || strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "no such host") || strings.Contains(errStr, "network is unreachable") || + strings.Contains(errStr, "TLS handshake failed") || + strings.Contains(errStr, "unexpected EOF") || + strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "remote error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline exceeded") { t.Skipf("skipping test: external service unavailable: %v", err) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index ba657851d..1ab8c6d39 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -800,6 +800,7 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": true, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": true, + "openai_clean_relay_enabled": false, "risk_control_enabled": false, "openai_free_account_repair_enabled": false, "openai_free_account_repair_weekly_threshold_usd": 60, @@ -1033,6 +1034,7 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": false, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": false, + "openai_clean_relay_enabled": false, "risk_control_enabled": false, "openai_free_account_repair_enabled": false, "openai_free_account_repair_weekly_threshold_usd": 60, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index b5a60a51c..53b6ae55a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -40,7 +40,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } clientStream := gjson.GetBytes(body, "stream").Bool() reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) - serviceTier := extractOpenAIServiceTierFromBody(body) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) @@ -59,7 +58,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } return nil, err } - serviceTier = extractOpenAIServiceTierFromBody(upstreamBody) + serviceTier := extractOpenAIServiceTierFromBody(upstreamBody) if clientStream { upstreamBody, err = ensureOpenAIChatStreamUsage(upstreamBody) if err != nil { 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 e0ad2f485..9bf77aa79 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -597,8 +597,15 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR require.Equal(t, 1, captureDialer.DialCount(), "passthrough 模式应直接建立上游 websocket") require.Len(t, upstreamConn.writes, 1, "passthrough 模式应透传首条 response.create") require.False(t, upstreamConn.writes[0]["previous_response_id"] != nil, "洁净中继 cold start 应清理客户端 previous_response_id") - require.Equal(t, "input_text", upstreamConn.writes[0]["input"].([]any)[0].(map[string]any)["type"]) - require.Equal(t, openAICleanRelayInstallationID(account.ID), upstreamConn.writes[0]["client_metadata"].(map[string]any)[openAICleanRelayInstallationField]) + upstreamInput, ok := upstreamConn.writes[0]["input"].([]any) + require.True(t, ok) + require.NotEmpty(t, upstreamInput) + upstreamInputItem, ok := upstreamInput[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "input_text", upstreamInputItem["type"]) + upstreamMetadata, ok := upstreamConn.writes[0]["client_metadata"].(map[string]any) + require.True(t, ok) + require.Equal(t, openAICleanRelayInstallationID(account.ID), upstreamMetadata[openAICleanRelayInstallationField]) require.NotEqual(t, "client-cache", upstreamConn.writes[0]["prompt_cache_key"]) require.Equal(t, openAICleanRelayInstallationID(account.ID), captureDialer.lastHeaders.Get(openAICleanRelayInstallationField)) require.NotEqual(t, "client-session", captureDialer.lastHeaders.Get("session_id")) diff --git a/backend/internal/service/scheduler_snapshot_hydration_test.go b/backend/internal/service/scheduler_snapshot_hydration_test.go index 0b32c2ade..478e89935 100644 --- a/backend/internal/service/scheduler_snapshot_hydration_test.go +++ b/backend/internal/service/scheduler_snapshot_hydration_test.go @@ -71,6 +71,7 @@ func TestOpenAISelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedul Schedulable: true, Concurrency: 1, Priority: 1, + GroupIDs: []int64{2}, Credentials: map[string]any{ "model_mapping": map[string]any{ "gpt-4": "gpt-4", @@ -87,6 +88,7 @@ func TestOpenAISelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedul Schedulable: true, Concurrency: 1, Priority: 1, + GroupIDs: []int64{2}, Credentials: map[string]any{ "api_key": "sk-live", "model_mapping": map[string]any{"gpt-4": "gpt-4"}, diff --git a/backend/internal/service/usage_record_worker_pool.go b/backend/internal/service/usage_record_worker_pool.go index 5da0b8902..e77502bf3 100644 --- a/backend/internal/service/usage_record_worker_pool.go +++ b/backend/internal/service/usage_record_worker_pool.go @@ -320,16 +320,24 @@ func (p *UsageRecordWorkerPool) execute(task UsageRecordTask) { defer func() { if recovered := recover(); recovered != nil { - logger.L().With( - zap.String("component", "service.usage_record_worker_pool"), - zap.Any("panic", recovered), - ).Error("usage_record.task_panic") + logUsageRecordTaskPanic(recovered) } }() task(ctx) } +func logUsageRecordTaskPanic(recovered any) { + defer func() { + _ = recover() + }() + + logger.L().With( + zap.String("component", "service.usage_record_worker_pool"), + zap.Any("panic", recovered), + ).Error("usage_record.task_panic") +} + func (p *UsageRecordWorkerPool) logDrop(reason string) { now := time.Now().UnixNano() last := p.lastDropLogNanos.Load()