Commit 34b8bbcb authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'dev' into release

parents 43dc23a4 6b36992d
...@@ -14,6 +14,19 @@ func resetViperWithJWTSecret(t *testing.T) { ...@@ -14,6 +14,19 @@ func resetViperWithJWTSecret(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
} }
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
viper.Reset()
t.Setenv("JWT_SECRET", "")
cfg, err := LoadForBootstrap()
if err != nil {
t.Fatalf("LoadForBootstrap() error: %v", err)
}
if cfg.JWT.Secret != "" {
t.Fatalf("LoadForBootstrap() should keep empty jwt.secret during bootstrap")
}
}
func TestNormalizeRunMode(t *testing.T) { func TestNormalizeRunMode(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
......
...@@ -9,5 +9,5 @@ var ProviderSet = wire.NewSet( ...@@ -9,5 +9,5 @@ var ProviderSet = wire.NewSet(
// ProvideConfig 提供应用配置 // ProvideConfig 提供应用配置
func ProvideConfig() (*Config, error) { func ProvideConfig() (*Config, error) {
return Load() return LoadForBootstrap()
} }
...@@ -13,13 +13,11 @@ import ( ...@@ -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) { ...@@ -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。
......
...@@ -5,6 +5,7 @@ package repository ...@@ -5,6 +5,7 @@ package repository
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"time" "time"
"github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent"
...@@ -66,6 +67,18 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { ...@@ -66,6 +67,18 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
// 创建 Ent 客户端,绑定到已配置的数据库驱动。 // 创建 Ent 客户端,绑定到已配置的数据库驱动。
client := ent.NewClient(ent.Driver(drv)) client := ent.NewClient(ent.Driver(drv))
// 启动阶段:从配置或数据库中确保系统密钥可用。
if err := ensureBootstrapSecrets(migrationCtx, client, cfg); err != nil {
_ = client.Close()
return nil, nil, err
}
// 在密钥补齐后执行完整配置校验,避免空 jwt.secret 导致服务运行时失败。
if err := cfg.Validate(); err != nil {
_ = client.Close()
return nil, nil, fmt.Errorf("validate config after secret bootstrap: %w", err)
}
// SIMPLE 模式:启动时补齐各平台默认分组。 // SIMPLE 模式:启动时补齐各平台默认分组。
// - anthropic/openai/gemini: 确保存在 <platform>-default // - anthropic/openai/gemini: 确保存在 <platform>-default
// - antigravity: 仅要求存在 >=2 个未软删除分组(用于 claude/gemini 混合调度场景) // - antigravity: 仅要求存在 >=2 个未软删除分组(用于 claude/gemini 混合调度场景)
......
...@@ -48,6 +48,11 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { ...@@ -48,6 +48,11 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.settings')").Scan(&settingsRegclass)) require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.settings')").Scan(&settingsRegclass))
require.True(t, settingsRegclass.Valid, "expected settings table to exist") require.True(t, settingsRegclass.Valid, "expected settings table to exist")
// security_secrets table should exist
var securitySecretsRegclass sql.NullString
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.security_secrets')").Scan(&securitySecretsRegclass))
require.True(t, securitySecretsRegclass.Valid, "expected security_secrets table to exist")
// user_allowed_groups table should exist // user_allowed_groups table should exist
var uagRegclass sql.NullString var uagRegclass sql.NullString
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.user_allowed_groups')").Scan(&uagRegclass)) require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.user_allowed_groups')").Scan(&uagRegclass))
......
package repository
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"strings"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/internal/config"
)
const securitySecretKeyJWT = "jwt_secret"
var readRandomBytes = rand.Read
func ensureBootstrapSecrets(ctx context.Context, client *ent.Client, cfg *config.Config) error {
if client == nil {
return fmt.Errorf("nil ent client")
}
if cfg == nil {
return fmt.Errorf("nil config")
}
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
if cfg.JWT.Secret != "" {
if err := createSecuritySecretIfAbsent(ctx, client, securitySecretKeyJWT, cfg.JWT.Secret); err != nil {
return fmt.Errorf("persist jwt secret: %w", err)
}
return nil
}
secret, created, err := getOrCreateGeneratedSecuritySecret(ctx, client, securitySecretKeyJWT, 32)
if err != nil {
return fmt.Errorf("ensure jwt secret: %w", err)
}
cfg.JWT.Secret = secret
if created {
log.Println("Warning: JWT secret auto-generated and persisted to database. Consider rotating to a managed secret for production.")
}
return nil
}
func getOrCreateGeneratedSecuritySecret(ctx context.Context, client *ent.Client, key string, byteLength int) (string, bool, error) {
existing, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(key)).Only(ctx)
if err == nil {
value := strings.TrimSpace(existing.Value)
if len([]byte(value)) < 32 {
return "", false, fmt.Errorf("stored secret %q must be at least 32 bytes", key)
}
return value, false, nil
}
if !ent.IsNotFound(err) {
return "", false, err
}
generated, err := generateHexSecret(byteLength)
if err != nil {
return "", false, err
}
if err := client.SecuritySecret.Create().
SetKey(key).
SetValue(generated).
OnConflictColumns(securitysecret.FieldKey).
DoNothing().
Exec(ctx); err != nil {
return "", false, err
}
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(key)).Only(ctx)
if err != nil {
return "", false, err
}
value := strings.TrimSpace(stored.Value)
if len([]byte(value)) < 32 {
return "", false, fmt.Errorf("stored secret %q must be at least 32 bytes", key)
}
return value, value == generated, nil
}
func createSecuritySecretIfAbsent(ctx context.Context, client *ent.Client, key, value string) error {
value = strings.TrimSpace(value)
if len([]byte(value)) < 32 {
return fmt.Errorf("secret %q must be at least 32 bytes", key)
}
_, err := client.SecuritySecret.Create().SetKey(key).SetValue(value).Save(ctx)
if err == nil || ent.IsConstraintError(err) {
return nil
}
return err
}
func generateHexSecret(byteLength int) (string, error) {
if byteLength <= 0 {
byteLength = 32
}
buf := make([]byte, byteLength)
if _, err := readRandomBytes(buf); err != nil {
return "", fmt.Errorf("generate random secret: %w", err)
}
return hex.EncodeToString(buf), nil
}
package repository
import (
"context"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
_ "modernc.org/sqlite"
)
func newSecuritySecretTestClient(t *testing.T) *dbent.Client {
t.Helper()
name := strings.ReplaceAll(t.Name(), "/", "_")
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", name)
db, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
_, err = db.Exec("PRAGMA foreign_keys = ON")
require.NoError(t, err)
drv := entsql.OpenDB(dialect.SQLite, db)
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestEnsureBootstrapSecretsNilInputs(t *testing.T) {
err := ensureBootstrapSecrets(context.Background(), nil, &config.Config{})
require.Error(t, err)
require.Contains(t, err.Error(), "nil ent client")
client := newSecuritySecretTestClient(t)
err = ensureBootstrapSecrets(context.Background(), client, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "nil config")
}
func TestEnsureBootstrapSecretsGenerateAndPersistJWTSecret(t *testing.T) {
client := newSecuritySecretTestClient(t)
cfg := &config.Config{}
err := ensureBootstrapSecrets(context.Background(), client, cfg)
require.NoError(t, err)
require.NotEmpty(t, cfg.JWT.Secret)
require.GreaterOrEqual(t, len([]byte(cfg.JWT.Secret)), 32)
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
require.NoError(t, err)
require.Equal(t, cfg.JWT.Secret, stored.Value)
}
func TestEnsureBootstrapSecretsLoadExistingJWTSecret(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().SetKey(securitySecretKeyJWT).SetValue("existing-jwt-secret-32bytes-long!!!!").Save(context.Background())
require.NoError(t, err)
cfg := &config.Config{}
err = ensureBootstrapSecrets(context.Background(), client, cfg)
require.NoError(t, err)
require.Equal(t, "existing-jwt-secret-32bytes-long!!!!", cfg.JWT.Secret)
}
func TestEnsureBootstrapSecretsRejectInvalidStoredSecret(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().SetKey(securitySecretKeyJWT).SetValue("too-short").Save(context.Background())
require.NoError(t, err)
cfg := &config.Config{}
err = ensureBootstrapSecrets(context.Background(), client, cfg)
require.Error(t, err)
require.Contains(t, err.Error(), "at least 32 bytes")
}
func TestEnsureBootstrapSecretsPersistConfiguredJWTSecret(t *testing.T) {
client := newSecuritySecretTestClient(t)
cfg := &config.Config{
JWT: config.JWTConfig{Secret: "configured-jwt-secret-32bytes-long!!"},
}
err := ensureBootstrapSecrets(context.Background(), client, cfg)
require.NoError(t, err)
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
require.NoError(t, err)
require.Equal(t, "configured-jwt-secret-32bytes-long!!", stored.Value)
}
func TestEnsureBootstrapSecretsConfiguredSecretTooShort(t *testing.T) {
client := newSecuritySecretTestClient(t)
cfg := &config.Config{JWT: config.JWTConfig{Secret: "short"}}
err := ensureBootstrapSecrets(context.Background(), client, cfg)
require.Error(t, err)
require.Contains(t, err.Error(), "at least 32 bytes")
}
func TestEnsureBootstrapSecretsConfiguredSecretDuplicateIgnored(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().
SetKey(securitySecretKeyJWT).
SetValue("existing-jwt-secret-32bytes-long!!!!").
Save(context.Background())
require.NoError(t, err)
cfg := &config.Config{JWT: config.JWTConfig{Secret: "another-configured-jwt-secret-32!!!!"}}
err = ensureBootstrapSecrets(context.Background(), client, cfg)
require.NoError(t, err)
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
require.NoError(t, err)
require.Equal(t, "existing-jwt-secret-32bytes-long!!!!", stored.Value)
}
func TestGetOrCreateGeneratedSecuritySecretTrimmedExistingValue(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().
SetKey("trimmed_key").
SetValue(" existing-trimmed-secret-32bytes-long!! ").
Save(context.Background())
require.NoError(t, err)
value, created, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "trimmed_key", 32)
require.NoError(t, err)
require.False(t, created)
require.Equal(t, "existing-trimmed-secret-32bytes-long!!", value)
}
func TestGetOrCreateGeneratedSecuritySecretQueryError(t *testing.T) {
client := newSecuritySecretTestClient(t)
require.NoError(t, client.Close())
_, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "closed_client_key", 32)
require.Error(t, err)
}
func TestGetOrCreateGeneratedSecuritySecretCreateValidationError(t *testing.T) {
client := newSecuritySecretTestClient(t)
tooLongKey := strings.Repeat("k", 101)
_, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, tooLongKey, 32)
require.Error(t, err)
}
func TestGetOrCreateGeneratedSecuritySecretConcurrentCreation(t *testing.T) {
client := newSecuritySecretTestClient(t)
const goroutines = 8
key := "concurrent_bootstrap_key"
values := make([]string, goroutines)
createdFlags := make([]bool, goroutines)
errs := make([]error, goroutines)
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
values[idx], createdFlags[idx], errs[idx] = getOrCreateGeneratedSecuritySecret(context.Background(), client, key, 32)
}(i)
}
wg.Wait()
for i := range errs {
require.NoError(t, errs[i])
require.NotEmpty(t, values[i])
}
for i := 1; i < len(values); i++ {
require.Equal(t, values[0], values[i])
}
createdCount := 0
for _, created := range createdFlags {
if created {
createdCount++
}
}
require.GreaterOrEqual(t, createdCount, 1)
require.LessOrEqual(t, createdCount, 1)
count, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(key)).Count(context.Background())
require.NoError(t, err)
require.Equal(t, 1, count)
}
func TestGetOrCreateGeneratedSecuritySecretGenerateError(t *testing.T) {
client := newSecuritySecretTestClient(t)
originalRead := readRandomBytes
readRandomBytes = func([]byte) (int, error) {
return 0, errors.New("boom")
}
t.Cleanup(func() {
readRandomBytes = originalRead
})
_, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "gen_error_key", 32)
require.Error(t, err)
require.Contains(t, err.Error(), "boom")
}
func TestCreateSecuritySecretIfAbsent(t *testing.T) {
client := newSecuritySecretTestClient(t)
err := createSecuritySecretIfAbsent(context.Background(), client, "abc", "short")
require.Error(t, err)
require.Contains(t, err.Error(), "at least 32 bytes")
err = createSecuritySecretIfAbsent(context.Background(), client, "abc", "valid-jwt-secret-value-32bytes-long")
require.NoError(t, err)
err = createSecuritySecretIfAbsent(context.Background(), client, "abc", "another-valid-secret-value-32bytes")
require.NoError(t, err)
count, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ("abc")).Count(context.Background())
require.NoError(t, err)
require.Equal(t, 1, count)
}
func TestCreateSecuritySecretIfAbsentValidationError(t *testing.T) {
client := newSecuritySecretTestClient(t)
err := createSecuritySecretIfAbsent(
context.Background(),
client,
strings.Repeat("k", 101),
"valid-jwt-secret-value-32bytes-long",
)
require.Error(t, err)
}
func TestGenerateHexSecretReadError(t *testing.T) {
originalRead := readRandomBytes
readRandomBytes = func([]byte) (int, error) {
return 0, errors.New("read random failed")
}
t.Cleanup(func() {
readRandomBytes = originalRead
})
_, err := generateHexSecret(32)
require.Error(t, err)
require.Contains(t, err.Error(), "read random failed")
}
func TestGenerateHexSecretLengths(t *testing.T) {
v1, err := generateHexSecret(0)
require.NoError(t, err)
require.Len(t, v1, 64)
_, err = hex.DecodeString(v1)
require.NoError(t, err)
v2, err := generateHexSecret(16)
require.NoError(t, err)
require.Len(t, v2, 32)
_, err = hex.DecodeString(v2)
require.NoError(t, err)
require.NotEqual(t, v1, v2)
}
...@@ -696,23 +696,27 @@ func (a *Account) IsMixedSchedulingEnabled() bool { ...@@ -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) IsOpenAIOAuthPassthroughEnabled() bool { func (a *Account) IsOpenAIPassthroughEnabled() bool {
if a == nil || a.Extra == nil { if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false return false
} }
v, ok := a.Extra["openai_oauth_passthrough"] if enabled, ok := a.Extra["openai_passthrough"].(bool); ok {
if !ok || v == nil { return enabled
return false
} }
enabled, ok := v.(bool) if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok {
if !ok { return enabled
return false
} }
return enabled return false
}
// IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。
func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
} }
// WindowCostSchedulability 窗口费用调度状态 // WindowCostSchedulability 窗口费用调度状态
......
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 ...@@ -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 ...@@ -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 ...@@ -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,
...@@ -1004,7 +1012,14 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough( ...@@ -1004,7 +1012,14 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough(
reqStream bool, reqStream bool,
startTime time.Time, startTime time.Time,
) (*OpenAIForwardResult, error) { ) (*OpenAIForwardResult, error) {
log.Printf("[OpenAI 透传] 已启用:account=%d name=%s", account.ID, account.Name) log.Printf(
"[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
account.ID,
account.Name,
account.Type,
reqModel,
reqStream,
)
// Get access token // Get access token
token, _, err := s.GetAccessToken(ctx, account) token, _, err := s.GetAccessToken(ctx, account)
...@@ -1012,7 +1027,7 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough( ...@@ -1012,7 +1027,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 +1107,29 @@ func (s *OpenAIGatewayService) forwardOAuthPassthrough( ...@@ -1092,14 +1107,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 +1153,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOAuthPassthrough( ...@@ -1123,16 +1153,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 +1421,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. ...@@ -1389,7 +1421,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 +2116,21 @@ func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, erro ...@@ -2084,6 +2116,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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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"))
}
-- 存储系统级密钥(如 JWT 签名密钥、TOTP 加密密钥)
CREATE TABLE IF NOT EXISTS security_secrets (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(100) NOT NULL UNIQUE,
value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_security_secrets_key ON security_secrets (key);
...@@ -866,77 +866,30 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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[]>([]) ...@@ -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(() => { ...@@ -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( ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 () => { ...@@ -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 () => { ...@@ -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) => { ...@@ -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) => { ...@@ -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) => { ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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) ...@@ -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( ...@@ -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 () => { ...@@ -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 () => { ...@@ -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 () => { ...@@ -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 @@ ...@@ -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 => { ...@@ -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 { ...@@ -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 { ...@@ -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 @@ ...@@ -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 => { ...@@ -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 => {
......
# 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 等)。
- 文本可换行完整展示,不被 `...` 截断。
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment