diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 75c758da..67d607fa 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -13,13 +13,11 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) // OpenAIGatewayHandler handles OpenAI API gateway requests @@ -118,22 +116,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } reqStream := streamResult.Bool() - userAgent := c.GetHeader("User-Agent") - isCodexCLI := openai.IsCodexCLIRequest(userAgent) || (h.cfg != nil && h.cfg.Gateway.ForceCodexCLI) - if !isCodexCLI { - existingInstructions := gjson.GetBytes(body, "instructions").String() - if strings.TrimSpace(existingInstructions) == "" { - if instructions := strings.TrimSpace(service.GetOpenCodeInstructions()); instructions != "" { - newBody, err := sjson.SetBytes(body, "instructions", instructions) - if err != nil { - h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to process request") - return - } - body = newBody - } - } - } - setOpsRequestContext(c, reqModel, reqStream, body) // 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。 diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ba3a3081..96ff5ca3 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -696,23 +696,27 @@ func (a *Account) IsMixedSchedulingEnabled() bool { return false } -// IsOpenAIOAuthPassthroughEnabled 返回 OpenAI OAuth 账号是否启用“原样透传(仅替换认证)”。 +// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用“自动透传(仅替换认证)”。 // -// 存储位置:accounts.extra.openai_oauth_passthrough。 +// 新字段:accounts.extra.openai_passthrough。 +// 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。 // 字段缺失或类型不正确时,按 false(关闭)处理。 +func (a *Account) IsOpenAIPassthroughEnabled() bool { + if a == nil || !a.IsOpenAI() || a.Extra == nil { + return false + } + if enabled, ok := a.Extra["openai_passthrough"].(bool); ok { + return enabled + } + if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok { + return enabled + } + return false +} + +// IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。 func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool { - if a == nil || a.Extra == nil { - return false - } - v, ok := a.Extra["openai_oauth_passthrough"] - if !ok || v == nil { - return false - } - enabled, ok := v.(bool) - if !ok { - return false - } - return enabled + return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled() } // WindowCostSchedulability 窗口费用调度状态 diff --git a/backend/internal/service/account_openai_passthrough_test.go b/backend/internal/service/account_openai_passthrough_test.go new file mode 100644 index 00000000..f7a0d1cf --- /dev/null +++ b/backend/internal/service/account_openai_passthrough_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccount_IsOpenAIPassthroughEnabled(t *testing.T) { + t.Run("新字段开启", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + "openai_passthrough": true, + }, + } + require.True(t, account.IsOpenAIPassthroughEnabled()) + }) + + t.Run("兼容旧字段", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_oauth_passthrough": true, + }, + } + require.True(t, account.IsOpenAIPassthroughEnabled()) + }) + + t.Run("非OpenAI账号始终关闭", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_passthrough": true, + }, + } + require.False(t, account.IsOpenAIPassthroughEnabled()) + }) + + t.Run("空额外配置默认关闭", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + } + require.False(t, account.IsOpenAIPassthroughEnabled()) + }) +} + +func TestAccount_IsOpenAIOAuthPassthroughEnabled(t *testing.T) { + t.Run("仅OAuth类型允许返回开启", func(t *testing.T) { + oauthAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_passthrough": true, + }, + } + require.True(t, oauthAccount.IsOpenAIOAuthPassthroughEnabled()) + + apiKeyAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + "openai_passthrough": true, + }, + } + require.False(t, apiKeyAccount.IsOpenAIOAuthPassthroughEnabled()) + }) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 3ff20978..0e9a2580 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -747,11 +747,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco originalModel := reqModel isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) - passthroughEnabled := account.Type == AccountTypeOAuth && account.IsOpenAIOAuthPassthroughEnabled() && isCodexCLI + passthroughEnabled := account.IsOpenAIPassthroughEnabled() if passthroughEnabled { // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。 reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel) - return s.forwardOAuthPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime) + return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime) } reqBody, err := getOpenAIRequestBodyMap(c, body) @@ -775,6 +775,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Track if body needs re-serialization bodyModified := false + // 非透传模式下,保持历史行为:非 Codex CLI 请求在 instructions 为空时注入默认指令。 + if !isCodexCLI && isInstructionsEmpty(reqBody) { + if instructions := strings.TrimSpace(GetOpenCodeInstructions()); instructions != "" { + reqBody["instructions"] = instructions + bodyModified = true + } + } + // 对所有请求执行模型映射(包含 Codex CLI)。 mappedModel := account.GetMappedModel(reqModel) if mappedModel != reqModel { @@ -994,7 +1002,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }, nil } -func (s *OpenAIGatewayService) forwardOAuthPassthrough( +func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ctx context.Context, c *gin.Context, account *Account, @@ -1012,7 +1020,7 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough( return nil, err } - upstreamReq, err := s.buildUpstreamRequestOAuthPassthrough(ctx, c, account, body, token) + upstreamReq, err := s.buildUpstreamRequestOpenAIPassthrough(ctx, c, account, body, token) if err != nil { return nil, err } @@ -1092,14 +1100,29 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough( }, nil } -func (s *OpenAIGatewayService) buildUpstreamRequestOAuthPassthrough( +func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( ctx context.Context, c *gin.Context, account *Account, body []byte, token string, ) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body)) + targetURL := openaiPlatformAPIURL + switch account.Type { + case AccountTypeOAuth: + targetURL = chatgptCodexURL + case AccountTypeAPIKey: + baseURL := account.GetOpenAIBaseURL() + if baseURL != "" { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, err + } + targetURL = buildOpenAIResponsesURL(validatedURL) + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, err } @@ -1123,16 +1146,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOAuthPassthrough( req.Header.Del("x-goog-api-key") req.Header.Set("authorization", "Bearer "+token) - // ChatGPT internal Codex API 必要头 - req.Host = "chatgpt.com" - if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { - req.Header.Set("chatgpt-account-id", chatgptAccountID) - } - if req.Header.Get("OpenAI-Beta") == "" { - req.Header.Set("OpenAI-Beta", "responses=experimental") - } - if req.Header.Get("originator") == "" { - req.Header.Set("originator", "codex_cli_rs") + // OAuth 透传到 ChatGPT internal API 时补齐必要头。 + if account.Type == AccountTypeOAuth { + req.Host = "chatgpt.com" + if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { + req.Header.Set("chatgpt-account-id", chatgptAccountID) + } + if req.Header.Get("OpenAI-Beta") == "" { + req.Header.Set("OpenAI-Beta", "responses=experimental") + } + if req.Header.Get("originator") == "" { + req.Header.Set("originator", "codex_cli_rs") + } } if req.Header.Get("content-type") == "" { @@ -1389,7 +1414,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = validatedURL + "/responses" + targetURL = buildOpenAIResponsesURL(validatedURL) } default: targetURL = openaiPlatformAPIURL @@ -2084,6 +2109,21 @@ func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, erro return normalized, nil } +// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 +// - base 以 /v1 结尾:追加 /responses +// - base 已是 /responses:原样返回 +// - 其他情况:追加 /v1/responses +func buildOpenAIResponsesURL(base string) string { + normalized := strings.TrimRight(strings.TrimSpace(base), "/") + if strings.HasSuffix(normalized, "/responses") { + return normalized + } + if strings.HasSuffix(normalized, "/v1") { + return normalized + "/responses" + } + return normalized + "/v1/responses" +} + func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 96805123..f6932469 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -88,7 +88,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchang Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -107,6 +107,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchang // 2) only auth is replaced; inbound auth/cookie are not forwarded require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "codex_cli_rs/0.1.0", upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Cookie")) require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key")) require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key")) @@ -154,7 +155,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *te Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": false}, + Extra: map[string]any{"openai_passthrough": false}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -207,7 +208,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_ResponseHeadersAllowXCodex(t *tes Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -249,7 +250,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -267,7 +268,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF require.True(t, arr[len(arr)-1].Passthrough) } -func TestOpenAIGatewayService_OAuthPassthrough_RequiresCodexUAOrForceFlag(t *testing.T) { +func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAStillPassthroughWhenEnabled(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -297,7 +298,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_RequiresCodexUAOrForceFlag(t *tes Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -305,16 +306,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_RequiresCodexUAOrForceFlag(t *tes _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) - // not codex, not forced => legacy transform should run - require.Contains(t, string(upstream.lastBody), `"store":false`) - require.Contains(t, string(upstream.lastBody), `"stream":true`) - - // now enable force flag => should passthrough and keep bytes - upstream2 := &httpUpstreamRecorder{resp: resp} - svc2 := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: true}}, httpUpstream: upstream2} - _, err = svc2.Forward(context.Background(), c, account, inputBody) - require.NoError(t, err) - require.Equal(t, inputBody, upstream2.lastBody) + require.Equal(t, inputBody, upstream.lastBody) + require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) } func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *testing.T) { @@ -352,7 +345,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *test Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -406,7 +399,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollec Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_oauth_passthrough": true}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), @@ -421,3 +414,48 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollec require.Equal(t, 7, result.Usage.OutputTokens) require.Equal(t, 3, result.Usage.CacheReadInputTokens) } + +func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "curl/8.0") + c.Request.Header.Set("X-Test", "keep") + + originalBody := []byte(`{"model":"gpt-5.2","stream":false,"max_output_tokens":128,"input":[{"type":"text","text":"hi"}]}`) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`)), + } + upstream := &httpUpstreamRecorder{resp: resp} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 456, + Name: "apikey-acc", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-api-key", "base_url": "https://api.openai.com"}, + Extra: map[string]any{"openai_passthrough": true}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + + _, err := svc.Forward(context.Background(), c, account, originalBody) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.Equal(t, originalBody, upstream.lastBody) + require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer sk-api-key", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "keep", upstream.lastReq.Header.Get("X-Test")) +} diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index a7290cbf..339044e5 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -866,77 +866,30 @@
- -
- - -
- - -
- -

- {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} - {{ - t('admin.accounts.supportsAllModels') - }} +

+

+ {{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}

- -
-
-

+

@@ -1562,6 +1573,36 @@

{{ t('admin.accounts.expiresAtHint') }}

+ +
+
+
+ +

+ {{ t('admin.accounts.openai.oauthPassthroughDesc') }} +

+
+ +
+
+
@@ -2143,6 +2184,7 @@ const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) +const openaiPassthroughEnabled = ref(false) const enableSoraOnOpenAIOAuth = ref(false) // OpenAI OAuth 时同时启用 Sora const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream @@ -2192,6 +2234,10 @@ const geminiSelectedTier = computed(() => { } }) +const isOpenAIModelRestrictionDisabled = computed(() => + form.platform === 'openai' && openaiPassthroughEnabled.value +) + const geminiQuotaDocs = { codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', aiStudio: 'https://ai.google.dev/pricing', @@ -2362,6 +2408,9 @@ watch( if (newPlatform !== 'anthropic') { interceptWarmupRequests.value = false } + if (newPlatform !== 'openai') { + openaiPassthroughEnabled.value = false + } // Reset OAuth states oauth.resetState() openaiOAuth.resetState() @@ -2615,6 +2664,7 @@ const resetForm = () => { customErrorCodeInput.value = null interceptWarmupRequests.value = false autoPauseOnExpired.value = true + openaiPassthroughEnabled.value = false enableSoraOnOpenAIOAuth.value = false // Reset quota control state windowCostEnabled.value = false @@ -2645,6 +2695,21 @@ const handleClose = () => { emit('close') } +const buildOpenAIPassthroughExtra = (base?: Record): Record | undefined => { + if (form.platform !== 'openai') { + return base + } + + const extra: Record = { ...(base || {}) } + if (openaiPassthroughEnabled.value) { + extra.openai_passthrough = true + } else { + delete extra.openai_passthrough + delete extra.openai_oauth_passthrough + } + return Object.keys(extra).length > 0 ? extra : undefined +} + // Helper function to create account with mixed channel warning handling const doCreateAccount = async (payload: any) => { submitting.value = true @@ -2775,10 +2840,12 @@ const handleSubmit = async () => { credentials.tier_id = geminiTierAIStudio.value } - // Add model mapping if configured - const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) - if (modelMapping) { - credentials.model_mapping = modelMapping + // Add model mapping if configured(OpenAI 开启自动透传时不应用) + if (!isOpenAIModelRestrictionDisabled.value) { + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + credentials.model_mapping = modelMapping + } } // Add custom error codes if enabled @@ -2796,10 +2863,12 @@ const handleSubmit = async () => { } form.credentials = credentials + const extra = buildOpenAIPassthroughExtra() await doCreateAccount({ ...form, group_ids: form.group_ids, + extra, auto_pause_on_expired: autoPauseOnExpired.value }) } @@ -2879,7 +2948,8 @@ const handleOpenAIExchange = async (authCode: string) => { if (!tokenInfo) return const credentials = openaiOAuth.buildCredentials(tokenInfo) - const extra = openaiOAuth.buildExtraInfo(tokenInfo) + const oauthExtra = openaiOAuth.buildExtraInfo(tokenInfo) as Record | undefined + const extra = buildOpenAIPassthroughExtra(oauthExtra) // 应用临时不可调度配置 if (!applyTempUnschedConfig(credentials)) { @@ -2916,10 +2986,12 @@ const handleOpenAIExchange = async (authCode: string) => { } // 建立关联关系 - const soraExtra = { - ...extra, + const soraExtra: Record = { + ...(extra || {}), linked_openai_account_id: String(openaiAccount.id) } + delete soraExtra.openai_passthrough + delete soraExtra.openai_oauth_passthrough await adminAPI.accounts.create({ name: `${form.name} (Sora)`, @@ -2991,7 +3063,8 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => { } const credentials = openaiOAuth.buildCredentials(tokenInfo) - const extra = openaiOAuth.buildExtraInfo(tokenInfo) + const oauthExtra = openaiOAuth.buildExtraInfo(tokenInfo) as Record | undefined + const extra = buildOpenAIPassthroughExtra(oauthExtra) // Generate account name with index for batch const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index a409265c..f6e4e92f 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -69,77 +69,30 @@
- -
- - -
- - -
- -

- {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} - {{ - t('admin.accounts.supportsAllModels') - }} +

+

+ {{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}

- -
-
-

+

@@ -694,9 +705,9 @@

{{ t('admin.accounts.expiresAtHint') }}

- +
@@ -708,16 +719,16 @@
@@ -1133,8 +1144,11 @@ const sessionIdleTimeout = ref(null) const tlsFingerprintEnabled = ref(false) const sessionIdMaskingEnabled = ref(false) -// OpenAI OAuth: passthrough mode toggle -const openaiOAuthPassthroughEnabled = ref(false) +// OpenAI 自动透传开关(OAuth/API Key) +const openaiPassthroughEnabled = ref(false) +const isOpenAIModelRestrictionDisabled = computed(() => + props.account?.platform === 'openai' && openaiPassthroughEnabled.value +) // Computed: current preset mappings based on platform const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) @@ -1223,10 +1237,10 @@ watch( const extra = newAccount.extra as Record | undefined mixedScheduling.value = extra?.mixed_scheduling === true - // Load OpenAI OAuth passthrough toggle (OpenAI OAuth only) - openaiOAuthPassthroughEnabled.value = false - if (newAccount.platform === 'openai' && newAccount.type === 'oauth') { - openaiOAuthPassthroughEnabled.value = extra?.openai_oauth_passthrough === true + // Load OpenAI passthrough toggle (OpenAI OAuth/API Key) + openaiPassthroughEnabled.value = false + if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { + openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true } // Load antigravity model mapping (Antigravity 只支持映射模式) @@ -1614,7 +1628,7 @@ const handleSubmit = async () => { if (props.account.type === 'apikey') { const currentCredentials = (props.account.credentials as Record) || {} const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value - const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value) // Always update credentials for apikey type to handle model mapping changes const newCredentials: Record = { @@ -1634,9 +1648,14 @@ const handleSubmit = async () => { return } - // Add model mapping if configured - if (modelMapping) { - newCredentials.model_mapping = modelMapping + // Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑) + if (shouldApplyModelMapping) { + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + newCredentials.model_mapping = modelMapping + } + } else if (currentCredentials.model_mapping) { + newCredentials.model_mapping = currentCredentials.model_mapping } // Add custom error codes if enabled @@ -1765,13 +1784,14 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - // For OpenAI OAuth accounts, handle passthrough mode in extra - if (props.account.platform === 'openai' && props.account.type === 'oauth') { + // For OpenAI OAuth/API Key accounts, handle passthrough mode in extra + if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) { const currentExtra = (props.account.extra as Record) || {} const newExtra: Record = { ...currentExtra } - if (openaiOAuthPassthroughEnabled.value) { - newExtra.openai_oauth_passthrough = true + if (openaiPassthroughEnabled.value) { + newExtra.openai_passthrough = true } else { + delete newExtra.openai_passthrough delete newExtra.openai_oauth_passthrough } updatePayload.extra = newExtra diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index fbb1942a..009aa017 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -121,7 +121,7 @@ @@ -284,16 +284,7 @@ const formatCacheTokens = (tokens: number): string => { } const formatUserAgent = (ua: string): string => { - // 提取主要客户端标识 - if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI' - if (ua.includes('Cursor')) return 'Cursor' - if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code' - if (ua.includes('Continue')) return 'Continue' - if (ua.includes('Cline')) return 'Cline' - if (ua.includes('OpenAI')) return 'OpenAI SDK' - if (ua.includes('anthropic')) return 'Anthropic SDK' - // 截断过长的 UA - return ua.length > 30 ? ua.substring(0, 30) + '...' : ua + return ua } const formatDuration = (ms: number | null | undefined): string => { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 95debb34..3b22ae33 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1533,7 +1533,8 @@ export default { apiKeyHint: 'Your OpenAI API Key', oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: - 'When enabled, applies to Codex CLI requests only: the gateway forwards request/response as-is and only swaps OAuth auth, while keeping billing/concurrency/audit. Disable to rollback if you hit 4xx or compatibility issues.', + 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', + modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', enableSora: 'Enable Sora simultaneously', enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 41a6e9ca..493dcac8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1682,7 +1682,8 @@ export default { apiKeyHint: '您的 OpenAI API Key', oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: - '开启后,仅对 Codex CLI 请求生效:网关将原样透传请求与响应内容,只替换 OAuth 认证并保留计费/并发/审计;如遇 4xx/兼容性问题可关闭回滚。', + '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', + modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', enableSora: '同时启用 Sora', enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号' }, diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index a768e9fd..30c38381 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -302,7 +302,7 @@ @@ -545,16 +545,7 @@ const formatDuration = (ms: number): string => { } const formatUserAgent = (ua: string): string => { - // 提取主要客户端标识 - if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI' - if (ua.includes('Cursor')) return 'Cursor' - if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code' - if (ua.includes('Continue')) return 'Continue' - if (ua.includes('Cline')) return 'Cline' - if (ua.includes('OpenAI')) return 'OpenAI SDK' - if (ua.includes('anthropic')) return 'Anthropic SDK' - // 截断过长的 UA - return ua.length > 30 ? ua.substring(0, 30) + '...' : ua + return ua } const formatTokens = (value: number): string => { diff --git a/openai-passthrough-regression-checklist-20260212.md b/openai-passthrough-regression-checklist-20260212.md new file mode 100644 index 00000000..fd21ccf4 --- /dev/null +++ b/openai-passthrough-regression-checklist-20260212.md @@ -0,0 +1,55 @@ +# OpenAI 自动透传回归测试清单(2026-02-12) + +## 目标 +- 验证 OpenAI 账号(OAuth/API Key)“自动透传”开关在创建页与编辑页可正确开关。 +- 验证开启后请求透传(仅替换认证),并保留计费/并发/审计等网关能力。 +- 验证 `User-Agent` 头透传到上游,且 Usage 页面展示原始 UA(不映射、不截断)。 + +## 自动化测试 +在仓库根目录执行: + +```bash +(cd backend && go test ./internal/service -run 'OpenAIGatewayService_.*Passthrough|TestAccount_IsOpenAIPassthroughEnabled|TestAccount_IsOpenAIOAuthPassthroughEnabled' -count=1) +(cd backend && go test ./internal/handler -run OpenAI -count=1) +pnpm --dir frontend run typecheck +pnpm --dir frontend run lint:check +``` + +预期: +- 所有命令退出码为 `0`。 + +## 手工回归场景 + +### 场景1:创建 OpenAI API Key 账号并开启自动透传 +1. 进入管理端账号创建弹窗,平台选择 OpenAI,类型选择 API Key。 +2. 打开“自动透传(仅替换认证)”开关并保存。 +3. 检查创建后的账号详情。 + +预期: +- `extra.openai_passthrough = true`。 +- 模型白名单/映射区域显示“不会生效”的提示。 + +### 场景2:编辑 OpenAI OAuth 账号开关可开可关 +1. 打开已有 OpenAI OAuth 账号编辑弹窗。 +2. 将“自动透传(仅替换认证)”从关切到开并保存。 +3. 再次进入编辑页,将开关从开切到关并保存。 + +预期: +- 开启后:`extra.openai_passthrough = true`。 +- 关闭后:`extra.openai_passthrough` 与 `extra.openai_oauth_passthrough` 均被清理。 + +### 场景3:请求链路透传(含 User-Agent) +1. 使用设置为“自动透传=开启”的 OpenAI 账号发起 `/v1/responses` 请求。 +2. 请求头设置 `User-Agent: codex_cli_rs/0.1.0`(或任意自定义 UA)。 + +预期: +- 上游收到与下游一致的 `User-Agent`。 +- 请求体保持原样透传,仅认证头被替换为目标账号令牌。 + +### 场景4:Usage 页面原样显示 User-Agent +1. 进入管理端用量表(Admin Usage)与用户侧用量页(User Usage)。 +2. 查找包含长 UA 的记录。 + +预期: +- 显示原始 UA 文本(不再映射为 VS Code/Cursor 等)。 +- 文本可换行完整展示,不被 `...` 截断。