feat(openai): 支持自动透传开关并透传 User-Agent
- OpenAI OAuth/API Key 统一支持自动透传开关,编辑页可开关\n- 透传模式仅替换认证并保留计费/并发/审计,修复 API Key responses 端点拼接\n- Usage 页面显示原始 User-Agent 且不截断,补充回归测试与清单
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 窗口费用调度状态
|
||||
|
||||
72
backend/internal/service/account_openai_passthrough_test.go
Normal file
72
backend/internal/service/account_openai_passthrough_test.go
Normal file
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -866,77 +866,30 @@
|
||||
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="isOpenAIModelRestrictionDisabled"
|
||||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||
>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<template v-else>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -945,13 +898,70 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
@@ -1022,19 +1032,20 @@
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
@@ -1562,6 +1573,36 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.oauthPassthrough') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.oauthPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -2143,6 +2184,7 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(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<string, unknown>): Record<string, unknown> | undefined => {
|
||||
if (form.platform !== 'openai') {
|
||||
return base
|
||||
}
|
||||
|
||||
const extra: Record<string, unknown> = { ...(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<string, unknown> | undefined
|
||||
const extra = buildOpenAIPassthroughExtra(oauthExtra)
|
||||
|
||||
// 应用临时不可调度配置
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
@@ -2916,10 +2986,12 @@ const handleOpenAIExchange = async (authCode: string) => {
|
||||
}
|
||||
|
||||
// 建立关联关系
|
||||
const soraExtra = {
|
||||
...extra,
|
||||
const soraExtra: Record<string, unknown> = {
|
||||
...(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<string, unknown> | undefined
|
||||
const extra = buildOpenAIPassthroughExtra(oauthExtra)
|
||||
|
||||
// Generate account name with index for batch
|
||||
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
|
||||
@@ -69,77 +69,30 @@
|
||||
<div v-if="account.platform !== 'gemini' && account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="isOpenAIModelRestrictionDisabled"
|
||||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||
>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<template v-else>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -148,13 +101,70 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
@@ -225,19 +235,20 @@
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
@@ -694,9 +705,9 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth passthrough toggle (OpenAI OAuth only) -->
|
||||
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -708,16 +719,16 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openaiOAuthPassthroughEnabled = !openaiOAuthPassthroughEnabled"
|
||||
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiOAuthPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
openaiPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiOAuthPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
openaiPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
@@ -1133,8 +1144,11 @@ const sessionIdleTimeout = ref<number | null>(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<string, unknown> | 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<string, unknown>) || {}
|
||||
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<string, unknown> = {
|
||||
@@ -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<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...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
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.'
|
||||
},
|
||||
|
||||
@@ -1682,7 +1682,8 @@ export default {
|
||||
apiKeyHint: '您的 OpenAI API Key',
|
||||
oauthPassthrough: '自动透传(仅替换认证)',
|
||||
oauthPassthroughDesc:
|
||||
'开启后,仅对 Codex CLI 请求生效:网关将原样透传请求与响应内容,只替换 OAuth 认证并保留计费/并发/审计;如遇 4xx/兼容性问题可关闭回滚。',
|
||||
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
enableSora: '同时启用 Sora',
|
||||
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
||||
},
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
55
openai-passthrough-regression-checklist-20260212.md
Normal file
55
openai-passthrough-regression-checklist-20260212.md
Normal file
@@ -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 等)。
|
||||
- 文本可换行完整展示,不被 `...` 截断。
|
||||
Reference in New Issue
Block a user