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/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
"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"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
||||||
@@ -118,22 +116,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
reqStream := streamResult.Bool()
|
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)
|
setOpsRequestContext(c, reqModel, reqStream, body)
|
||||||
|
|
||||||
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
|
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
|
||||||
|
|||||||
@@ -696,23 +696,27 @@ func (a *Account) IsMixedSchedulingEnabled() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOpenAIOAuthPassthroughEnabled 返回 OpenAI OAuth 账号是否启用“原样透传(仅替换认证)”。
|
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用“自动透传(仅替换认证)”。
|
||||||
//
|
//
|
||||||
// 存储位置:accounts.extra.openai_oauth_passthrough。
|
// 新字段:accounts.extra.openai_passthrough。
|
||||||
|
// 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
|
||||||
// 字段缺失或类型不正确时,按 false(关闭)处理。
|
// 字段缺失或类型不正确时,按 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 {
|
func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
|
||||||
if a == nil || a.Extra == nil {
|
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WindowCostSchedulability 窗口费用调度状态
|
// 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
|
originalModel := reqModel
|
||||||
|
|
||||||
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
|
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 {
|
if passthroughEnabled {
|
||||||
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
|
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
|
||||||
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel)
|
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)
|
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
|
// Track if body needs re-serialization
|
||||||
bodyModified := false
|
bodyModified := false
|
||||||
|
|
||||||
|
// 非透传模式下,保持历史行为:非 Codex CLI 请求在 instructions 为空时注入默认指令。
|
||||||
|
if !isCodexCLI && isInstructionsEmpty(reqBody) {
|
||||||
|
if instructions := strings.TrimSpace(GetOpenCodeInstructions()); instructions != "" {
|
||||||
|
reqBody["instructions"] = instructions
|
||||||
|
bodyModified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 对所有请求执行模型映射(包含 Codex CLI)。
|
// 对所有请求执行模型映射(包含 Codex CLI)。
|
||||||
mappedModel := account.GetMappedModel(reqModel)
|
mappedModel := account.GetMappedModel(reqModel)
|
||||||
if mappedModel != reqModel {
|
if mappedModel != reqModel {
|
||||||
@@ -994,7 +1002,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) forwardOAuthPassthrough(
|
func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
account *Account,
|
account *Account,
|
||||||
@@ -1012,7 +1020,7 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamReq, err := s.buildUpstreamRequestOAuthPassthrough(ctx, c, account, body, token)
|
upstreamReq, err := s.buildUpstreamRequestOpenAIPassthrough(ctx, c, account, body, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1092,14 +1100,29 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) buildUpstreamRequestOAuthPassthrough(
|
func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
account *Account,
|
account *Account,
|
||||||
body []byte,
|
body []byte,
|
||||||
token string,
|
token string,
|
||||||
) (*http.Request, error) {
|
) (*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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1123,16 +1146,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOAuthPassthrough(
|
|||||||
req.Header.Del("x-goog-api-key")
|
req.Header.Del("x-goog-api-key")
|
||||||
req.Header.Set("authorization", "Bearer "+token)
|
req.Header.Set("authorization", "Bearer "+token)
|
||||||
|
|
||||||
// ChatGPT internal Codex API 必要头
|
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
|
||||||
req.Host = "chatgpt.com"
|
if account.Type == AccountTypeOAuth {
|
||||||
if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" {
|
req.Host = "chatgpt.com"
|
||||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
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("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("originator") == "" {
|
||||||
|
req.Header.Set("originator", "codex_cli_rs")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Header.Get("content-type") == "" {
|
if req.Header.Get("content-type") == "" {
|
||||||
@@ -1389,7 +1414,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetURL = validatedURL + "/responses"
|
targetURL = buildOpenAIResponsesURL(validatedURL)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
targetURL = openaiPlatformAPIURL
|
targetURL = openaiPlatformAPIURL
|
||||||
@@ -2084,6 +2109,21 @@ func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, erro
|
|||||||
return normalized, nil
|
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 {
|
func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
|
||||||
// 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化
|
// 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化
|
||||||
if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel {
|
if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchang
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -107,6 +107,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchang
|
|||||||
|
|
||||||
// 2) only auth is replaced; inbound auth/cookie are not forwarded
|
// 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, "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("Cookie"))
|
||||||
require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key"))
|
require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key"))
|
||||||
require.Empty(t, upstream.lastReq.Header.Get("X-Goog-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,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -207,7 +208,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_ResponseHeadersAllowXCodex(t *tes
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -249,7 +250,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -267,7 +268,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF
|
|||||||
require.True(t, arr[len(arr)-1].Passthrough)
|
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)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -297,7 +298,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_RequiresCodexUAOrForceFlag(t *tes
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -305,16 +306,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_RequiresCodexUAOrForceFlag(t *tes
|
|||||||
|
|
||||||
_, err := svc.Forward(context.Background(), c, account, inputBody)
|
_, err := svc.Forward(context.Background(), c, account, inputBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// not codex, not forced => legacy transform should run
|
require.Equal(t, inputBody, upstream.lastBody)
|
||||||
require.Contains(t, string(upstream.lastBody), `"store":false`)
|
require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent"))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *testing.T) {
|
func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *testing.T) {
|
||||||
@@ -352,7 +345,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *test
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -406,7 +399,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollec
|
|||||||
Type: AccountTypeOAuth,
|
Type: AccountTypeOAuth,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
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,
|
Status: StatusActive,
|
||||||
Schedulable: true,
|
Schedulable: true,
|
||||||
RateMultiplier: f64p(1),
|
RateMultiplier: f64p(1),
|
||||||
@@ -421,3 +414,48 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollec
|
|||||||
require.Equal(t, 7, result.Usage.OutputTokens)
|
require.Equal(t, 7, result.Usage.OutputTokens)
|
||||||
require.Equal(t, 3, result.Usage.CacheReadInputTokens)
|
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">
|
<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>
|
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
|
||||||
<!-- Mode Toggle -->
|
<div
|
||||||
<div class="mb-4 flex gap-2">
|
v-if="isOpenAIModelRestrictionDisabled"
|
||||||
<button
|
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||||
type="button"
|
>
|
||||||
@click="modelRestrictionMode = 'whitelist'"
|
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
:class="[
|
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||||
'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>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mapping Mode -->
|
<template v-else>
|
||||||
<div v-else>
|
<!-- Mode Toggle -->
|
||||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
<div class="mb-4 flex gap-2">
|
||||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
<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
|
<svg
|
||||||
class="mr-1 inline h-4 w-4"
|
class="mr-1.5 inline h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -945,13 +898,70 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
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>
|
</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>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Model Mapping List -->
|
||||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||||
<div
|
<div
|
||||||
@@ -1022,19 +1032,20 @@
|
|||||||
{{ t('admin.accounts.addMapping') }}
|
{{ t('admin.accounts.addMapping') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Quick Add Buttons -->
|
<!-- Quick Add Buttons -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="preset in presetMappings"
|
v-for="preset in presetMappings"
|
||||||
:key="preset.label"
|
:key="preset.label"
|
||||||
type="button"
|
type="button"
|
||||||
@click="addPresetMapping(preset.from, preset.to)"
|
@click="addPresetMapping(preset.from, preset.to)"
|
||||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||||
>
|
>
|
||||||
+ {{ preset.label }}
|
+ {{ preset.label }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Error Codes Section -->
|
<!-- Custom Error Codes Section -->
|
||||||
@@ -1562,6 +1573,36 @@
|
|||||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -2143,6 +2184,7 @@ const selectedErrorCodes = ref<number[]>([])
|
|||||||
const customErrorCodeInput = ref<number | null>(null)
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
const interceptWarmupRequests = ref(false)
|
const interceptWarmupRequests = ref(false)
|
||||||
const autoPauseOnExpired = ref(true)
|
const autoPauseOnExpired = ref(true)
|
||||||
|
const openaiPassthroughEnabled = ref(false)
|
||||||
const enableSoraOnOpenAIOAuth = ref(false) // OpenAI OAuth 时同时启用 Sora
|
const enableSoraOnOpenAIOAuth = ref(false) // OpenAI OAuth 时同时启用 Sora
|
||||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
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 = {
|
const geminiQuotaDocs = {
|
||||||
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
|
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
|
||||||
aiStudio: 'https://ai.google.dev/pricing',
|
aiStudio: 'https://ai.google.dev/pricing',
|
||||||
@@ -2362,6 +2408,9 @@ watch(
|
|||||||
if (newPlatform !== 'anthropic') {
|
if (newPlatform !== 'anthropic') {
|
||||||
interceptWarmupRequests.value = false
|
interceptWarmupRequests.value = false
|
||||||
}
|
}
|
||||||
|
if (newPlatform !== 'openai') {
|
||||||
|
openaiPassthroughEnabled.value = false
|
||||||
|
}
|
||||||
// Reset OAuth states
|
// Reset OAuth states
|
||||||
oauth.resetState()
|
oauth.resetState()
|
||||||
openaiOAuth.resetState()
|
openaiOAuth.resetState()
|
||||||
@@ -2615,6 +2664,7 @@ const resetForm = () => {
|
|||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
interceptWarmupRequests.value = false
|
interceptWarmupRequests.value = false
|
||||||
autoPauseOnExpired.value = true
|
autoPauseOnExpired.value = true
|
||||||
|
openaiPassthroughEnabled.value = false
|
||||||
enableSoraOnOpenAIOAuth.value = false
|
enableSoraOnOpenAIOAuth.value = false
|
||||||
// Reset quota control state
|
// Reset quota control state
|
||||||
windowCostEnabled.value = false
|
windowCostEnabled.value = false
|
||||||
@@ -2645,6 +2695,21 @@ const handleClose = () => {
|
|||||||
emit('close')
|
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
|
// Helper function to create account with mixed channel warning handling
|
||||||
const doCreateAccount = async (payload: any) => {
|
const doCreateAccount = async (payload: any) => {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -2775,10 +2840,12 @@ const handleSubmit = async () => {
|
|||||||
credentials.tier_id = geminiTierAIStudio.value
|
credentials.tier_id = geminiTierAIStudio.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model mapping if configured
|
// Add model mapping if configured(OpenAI 开启自动透传时不应用)
|
||||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
if (!isOpenAIModelRestrictionDisabled.value) {
|
||||||
if (modelMapping) {
|
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||||
credentials.model_mapping = modelMapping
|
if (modelMapping) {
|
||||||
|
credentials.model_mapping = modelMapping
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom error codes if enabled
|
// Add custom error codes if enabled
|
||||||
@@ -2796,10 +2863,12 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form.credentials = credentials
|
form.credentials = credentials
|
||||||
|
const extra = buildOpenAIPassthroughExtra()
|
||||||
|
|
||||||
await doCreateAccount({
|
await doCreateAccount({
|
||||||
...form,
|
...form,
|
||||||
group_ids: form.group_ids,
|
group_ids: form.group_ids,
|
||||||
|
extra,
|
||||||
auto_pause_on_expired: autoPauseOnExpired.value
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2879,7 +2948,8 @@ const handleOpenAIExchange = async (authCode: string) => {
|
|||||||
if (!tokenInfo) return
|
if (!tokenInfo) return
|
||||||
|
|
||||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
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)) {
|
if (!applyTempUnschedConfig(credentials)) {
|
||||||
@@ -2916,10 +2986,12 @@ const handleOpenAIExchange = async (authCode: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 建立关联关系
|
// 建立关联关系
|
||||||
const soraExtra = {
|
const soraExtra: Record<string, unknown> = {
|
||||||
...extra,
|
...(extra || {}),
|
||||||
linked_openai_account_id: String(openaiAccount.id)
|
linked_openai_account_id: String(openaiAccount.id)
|
||||||
}
|
}
|
||||||
|
delete soraExtra.openai_passthrough
|
||||||
|
delete soraExtra.openai_oauth_passthrough
|
||||||
|
|
||||||
await adminAPI.accounts.create({
|
await adminAPI.accounts.create({
|
||||||
name: `${form.name} (Sora)`,
|
name: `${form.name} (Sora)`,
|
||||||
@@ -2991,7 +3063,8 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
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
|
// Generate account name with index for batch
|
||||||
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
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">
|
<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>
|
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||||
|
|
||||||
<!-- Mode Toggle -->
|
<div
|
||||||
<div class="mb-4 flex gap-2">
|
v-if="isOpenAIModelRestrictionDisabled"
|
||||||
<button
|
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||||
type="button"
|
>
|
||||||
@click="modelRestrictionMode = 'whitelist'"
|
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
:class="[
|
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||||
'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>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mapping Mode -->
|
<template v-else>
|
||||||
<div v-else>
|
<!-- Mode Toggle -->
|
||||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
<div class="mb-4 flex gap-2">
|
||||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
<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
|
<svg
|
||||||
class="mr-1 inline h-4 w-4"
|
class="mr-1.5 inline h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -148,13 +101,70 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
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>
|
</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>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Model Mapping List -->
|
||||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||||
<div
|
<div
|
||||||
@@ -225,19 +235,20 @@
|
|||||||
{{ t('admin.accounts.addMapping') }}
|
{{ t('admin.accounts.addMapping') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Quick Add Buttons -->
|
<!-- Quick Add Buttons -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="preset in presetMappings"
|
v-for="preset in presetMappings"
|
||||||
:key="preset.label"
|
:key="preset.label"
|
||||||
type="button"
|
type="button"
|
||||||
@click="addPresetMapping(preset.from, preset.to)"
|
@click="addPresetMapping(preset.from, preset.to)"
|
||||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||||
>
|
>
|
||||||
+ {{ preset.label }}
|
+ {{ preset.label }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Error Codes Section -->
|
<!-- Custom Error Codes Section -->
|
||||||
@@ -694,9 +705,9 @@
|
|||||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI OAuth passthrough toggle (OpenAI OAuth only) -->
|
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
|
||||||
<div
|
<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"
|
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -708,16 +719,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openaiOAuthPassthroughEnabled = !openaiOAuthPassthroughEnabled"
|
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||||||
:class="[
|
: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',
|
'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
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
'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>
|
</button>
|
||||||
@@ -1133,8 +1144,11 @@ const sessionIdleTimeout = ref<number | null>(null)
|
|||||||
const tlsFingerprintEnabled = ref(false)
|
const tlsFingerprintEnabled = ref(false)
|
||||||
const sessionIdMaskingEnabled = ref(false)
|
const sessionIdMaskingEnabled = ref(false)
|
||||||
|
|
||||||
// OpenAI OAuth: passthrough mode toggle
|
// OpenAI 自动透传开关(OAuth/API Key)
|
||||||
const openaiOAuthPassthroughEnabled = ref(false)
|
const openaiPassthroughEnabled = ref(false)
|
||||||
|
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||||||
|
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
|
||||||
|
)
|
||||||
|
|
||||||
// Computed: current preset mappings based on platform
|
// Computed: current preset mappings based on platform
|
||||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||||
@@ -1223,10 +1237,10 @@ watch(
|
|||||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||||
|
|
||||||
// Load OpenAI OAuth passthrough toggle (OpenAI OAuth only)
|
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||||||
openaiOAuthPassthroughEnabled.value = false
|
openaiPassthroughEnabled.value = false
|
||||||
if (newAccount.platform === 'openai' && newAccount.type === 'oauth') {
|
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
|
||||||
openaiOAuthPassthroughEnabled.value = extra?.openai_oauth_passthrough === true
|
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||||
@@ -1614,7 +1628,7 @@ const handleSubmit = async () => {
|
|||||||
if (props.account.type === 'apikey') {
|
if (props.account.type === 'apikey') {
|
||||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||||
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
|
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
|
// Always update credentials for apikey type to handle model mapping changes
|
||||||
const newCredentials: Record<string, unknown> = {
|
const newCredentials: Record<string, unknown> = {
|
||||||
@@ -1634,9 +1648,14 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model mapping if configured
|
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
|
||||||
if (modelMapping) {
|
if (shouldApplyModelMapping) {
|
||||||
newCredentials.model_mapping = modelMapping
|
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
|
// Add custom error codes if enabled
|
||||||
@@ -1765,13 +1784,14 @@ const handleSubmit = async () => {
|
|||||||
updatePayload.extra = newExtra
|
updatePayload.extra = newExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
// For OpenAI OAuth accounts, handle passthrough mode in extra
|
// For OpenAI OAuth/API Key accounts, handle passthrough mode in extra
|
||||||
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
|
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
|
||||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||||
if (openaiOAuthPassthroughEnabled.value) {
|
if (openaiPassthroughEnabled.value) {
|
||||||
newExtra.openai_oauth_passthrough = true
|
newExtra.openai_passthrough = true
|
||||||
} else {
|
} else {
|
||||||
|
delete newExtra.openai_passthrough
|
||||||
delete newExtra.openai_oauth_passthrough
|
delete newExtra.openai_oauth_passthrough
|
||||||
}
|
}
|
||||||
updatePayload.extra = newExtra
|
updatePayload.extra = newExtra
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-user_agent="{ row }">
|
<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>
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -284,16 +284,7 @@ const formatCacheTokens = (tokens: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatUserAgent = (ua: string): string => {
|
const formatUserAgent = (ua: string): string => {
|
||||||
// 提取主要客户端标识
|
return ua
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDuration = (ms: number | null | undefined): string => {
|
const formatDuration = (ms: number | null | undefined): string => {
|
||||||
|
|||||||
@@ -1533,7 +1533,8 @@ export default {
|
|||||||
apiKeyHint: 'Your OpenAI API Key',
|
apiKeyHint: 'Your OpenAI API Key',
|
||||||
oauthPassthrough: 'Auto passthrough (auth only)',
|
oauthPassthrough: 'Auto passthrough (auth only)',
|
||||||
oauthPassthroughDesc:
|
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',
|
enableSora: 'Enable Sora simultaneously',
|
||||||
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
|
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1682,7 +1682,8 @@ export default {
|
|||||||
apiKeyHint: '您的 OpenAI API Key',
|
apiKeyHint: '您的 OpenAI API Key',
|
||||||
oauthPassthrough: '自动透传(仅替换认证)',
|
oauthPassthrough: '自动透传(仅替换认证)',
|
||||||
oauthPassthroughDesc:
|
oauthPassthroughDesc:
|
||||||
'开启后,仅对 Codex CLI 请求生效:网关将原样透传请求与响应内容,只替换 OAuth 认证并保留计费/并发/审计;如遇 4xx/兼容性问题可关闭回滚。',
|
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
|
||||||
|
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||||
enableSora: '同时启用 Sora',
|
enableSora: '同时启用 Sora',
|
||||||
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -302,7 +302,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-user_agent="{ row }">
|
<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>
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -545,16 +545,7 @@ const formatDuration = (ms: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatUserAgent = (ua: string): string => {
|
const formatUserAgent = (ua: string): string => {
|
||||||
// 提取主要客户端标识
|
return ua
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTokens = (value: number): string => {
|
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