Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/internal/service/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,15 @@ func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool {
return ok && v
}

// IsForceCodexIdentityEnabled 是否强制固定上游 Codex CLI 身份(accounts.extra.force_codex_identity)。
// 与 codex_cli_only 相互独立:该开关限制入站客户端,本开关固定出站 UA,身份配对仍由终态逻辑完成。
func (a *Account) IsForceCodexIdentityEnabled() bool {
if a == nil || !a.IsOpenAIOAuth() {
return false
}
return resolveAccountExtraBool(a.Extra, "force_codex_identity")
}

// WindowCostSchedulability 窗口费用调度状态
type WindowCostSchedulability int

Expand Down
66 changes: 66 additions & 0 deletions backend/internal/service/account_force_codex_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package service

import (
"testing"

"github.com/stretchr/testify/require"
)

// 类型容错(string/number 等)由 resolveAccountExtraBool 自身覆盖,这里只测
// IsForceCodexIdentityEnabled 特有的分支:默认值、开启、账号类型门禁。
func TestAccount_IsForceCodexIdentityEnabled(t *testing.T) {
cases := []struct {
name string
account *Account
want bool
}{
{
name: "字段缺失默认关闭",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
want: false,
},
{
name: "开启",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"force_codex_identity": true}},
want: true,
},
{
name: "非OAuth账号-APIKey始终关闭",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Extra: map[string]any{"force_codex_identity": true}},
want: false,
},
{
name: "非OAuth账号-setup-token始终关闭",
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{"force_codex_identity": true}},
want: false,
},
{
name: "非OAuth账号-其他平台始终关闭",
account: &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Extra: map[string]any{"force_codex_identity": true}},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, tc.account.IsForceCodexIdentityEnabled())
})
}

t.Run("与codex_cli_only互相独立", func(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"codex_cli_only": true, "force_codex_identity": false},
}
require.True(t, account.IsCodexCLIOnlyEnabled())
require.False(t, account.IsForceCodexIdentityEnabled())

account2 := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"codex_cli_only": false, "force_codex_identity": true},
}
require.False(t, account2.IsCodexCLIOnlyEnabled())
require.True(t, account2.IsForceCodexIdentityEnabled())
})
}
24 changes: 24 additions & 0 deletions backend/internal/service/openai_codex_identity_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package service

import (
"context"
"net/http"
)

// resolveOpenAIForceCodexIdentityEnabled 解析账号到有效母账号(spark 影子继承母账号开关,
// 不在影子自身 extra 复制),返回 force_codex_identity 状态。
func resolveOpenAIForceCodexIdentityEnabled(ctx context.Context, repo AccountRepository, account *Account) (bool, error) {
credAccount, err := resolveCredentialAccount(ctx, repo, account)
if err != nil {
return false, err
}
return credAccount.IsForceCodexIdentityEnabled(), nil
}

// applyForcedCodexCLIUserAgent 将最终出站 UA 固定为 Codex CLI。originator 与 version
// 继续由紧随其后的 enforceCodexIdentityHeaders 统一配对和校正。
func applyForcedCodexCLIUserAgent(header http.Header) {
if header != nil {
header.Set("user-agent", codexCLIUserAgent)
}
}
67 changes: 67 additions & 0 deletions backend/internal/service/openai_codex_identity_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package service

import (
"context"
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

// 账号自身开关语义(默认关闭/非OAuth等)已由 TestAccount_IsForceCodexIdentityEnabled 覆盖,
// 本测试只针对 resolveOpenAIForceCodexIdentityEnabled 自身的母账号解析分支:普通/影子/坏父。
func TestResolveOpenAIForceCodexIdentityEnabled(t *testing.T) {
ctx := context.Background()
pid := int64(100)

t.Run("普通账号-读取自身开关", func(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"force_codex_identity": true},
}
repo := newStubCredRepo(nil)
enabled, err := resolveOpenAIForceCodexIdentityEnabled(ctx, repo, account)
require.NoError(t, err)
require.True(t, enabled)
})

t.Run("spark影子继承母账号开关(影子自身无extra)", func(t *testing.T) {
parent := &Account{
ID: 100,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"force_codex_identity": true},
}
shadow := &Account{ID: 200, ParentAccountID: &pid, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: nil}
repo := newStubCredRepo(parent)
enabled, err := resolveOpenAIForceCodexIdentityEnabled(ctx, repo, shadow)
require.NoError(t, err)
require.True(t, enabled)
})

t.Run("母账号损坏时返回错误", func(t *testing.T) {
shadow := &Account{ID: 200, ParentAccountID: &pid, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
badRepo := newStubCredRepo(&Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
_, err := resolveOpenAIForceCodexIdentityEnabled(ctx, badRepo, shadow)
require.Error(t, err)
})
}

func TestApplyForcedCodexCLIUserAgent(t *testing.T) {
header := http.Header{}
header.Set("user-agent", "curl/8.0")
header.Set("originator", "opencode")
header.Set("version", "0.125.0")

applyForcedCodexCLIUserAgent(header)

require.Equal(t, codexCLIUserAgent, header.Get("user-agent"))
require.Equal(t, "opencode", header.Get("originator"))

enforceCodexIdentityHeaders(header)
require.Equal(t, codexCLIUserAgent, header.Get("user-agent"))
require.Equal(t, "codex_cli_rs", header.Get("originator"))
require.Equal(t, codexCLIVersion, header.Get("version"))
require.NotPanics(t, func() { applyForcedCodexCLIUserAgent(nil) })
}
211 changes: 211 additions & 0 deletions backend/internal/service/openai_gateway_force_codex_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package service

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

const forceCodexIdentityTUIUserAgent = "codex-tui/0.145.0 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.0)"

func newForceCodexIdentityGinContext(path string, headers map[string]string) *gin.Context {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
for key, value := range headers {
c.Request.Header.Set(key, value)
}
return c
}

func newForceCodexIdentityOAuthAccount(extra map[string]any) *Account {
return &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"},
Extra: extra,
}
}

func newForceCodexIdentityPassthroughUpstream() *httpUpstreamRecorder {
return &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}},
Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")),
}}
}

func newForceCodexIdentityPassthroughAccount(id int64, enabled bool) *Account {
extra := map[string]any{"openai_passthrough": true}
if enabled {
extra["force_codex_identity"] = true
}
return &Account{
ID: id,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: extra,
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
}

func forceCodexIdentityInputHeaders() map[string]string {
return map[string]string{
"User-Agent": forceCodexIdentityTUIUserAgent,
"originator": "opencode",
}
}

func TestOpenAIBuildUpstreamRequest_ForceCodexIdentity(t *testing.T) {
tests := []struct {
name string
path string
enabled bool
wantUserAgent string
wantOriginator string
wantVersion string
}{
{
name: "默认关闭时保留最终UA并由终态逻辑自动配对",
path: "/v1/responses",
wantUserAgent: forceCodexIdentityTUIUserAgent,
wantOriginator: "codex-tui",
},
{
name: "标准Responses开启时固定UA并由终态逻辑配对",
path: "/v1/responses",
enabled: true,
wantUserAgent: codexCLIUserAgent,
wantOriginator: "codex_cli_rs",
},
{
name: "compact开启时固定Codex CLI UA",
path: "/responses/compact",
enabled: true,
wantUserAgent: codexCLIUserAgent,
wantOriginator: "codex_cli_rs",
wantVersion: codexCLIVersion,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
extra := map[string]any{}
if tt.enabled {
extra["force_codex_identity"] = true
}
c := newForceCodexIdentityGinContext(tt.path, forceCodexIdentityInputHeaders())
svc := &OpenAIGatewayService{cfg: &config.Config{}}
account := newForceCodexIdentityOAuthAccount(extra)

req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", false)
require.NoError(t, err)
require.Equal(t, tt.wantUserAgent, req.Header.Get("user-agent"))
require.Equal(t, tt.wantOriginator, req.Header.Get("originator"))
require.Equal(t, tt.wantVersion, req.Header.Get("version"))
})
}
}

func TestOpenAIBuildUpstreamRequest_ForceCodexIdentity_MessagesBridgeIsUnchanged(t *testing.T) {
c := newForceCodexIdentityGinContext("/v1/responses", map[string]string{
"User-Agent": "luna/1.0.0",
"originator": "opencode",
})
setOpenAICompatMessagesBridgeContext(c, true)
svc := &OpenAIGatewayService{cfg: &config.Config{}}
account := newForceCodexIdentityOAuthAccount(map[string]any{"force_codex_identity": true})

req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5.5"}`), "token", false, "", false)
require.NoError(t, err)
require.Equal(t, "luna/1.0.0", req.Header.Get("user-agent"))
require.Empty(t, req.Header.Get("originator"))
require.Empty(t, req.Header.Get("OpenAI-Beta"))
}

func TestOpenAIPassthrough_ForceCodexIdentity(t *testing.T) {
const body = `{"model":"gpt-5.2","stream":true,"store":false,"input":[{"type":"text","text":"hi"}]}`
tests := []struct {
name string
enabled bool
wantUserAgent string
wantOriginator string
}{
{
name: "默认关闭时保留最终UA并由终态逻辑自动配对",
wantUserAgent: forceCodexIdentityTUIUserAgent,
wantOriginator: "codex-tui",
},
{
name: "开启时固定Codex CLI UA",
enabled: true,
wantUserAgent: codexCLIUserAgent,
wantOriginator: "codex_cli_rs",
},
}

for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newForceCodexIdentityGinContext("/v1/responses", forceCodexIdentityInputHeaders())
upstream := newForceCodexIdentityPassthroughUpstream()
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := newForceCodexIdentityPassthroughAccount(int64(i+1), tt.enabled)

_, err := svc.Forward(context.Background(), c, account, []byte(body))
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, tt.wantUserAgent, upstream.lastReq.Header.Get("user-agent"))
require.Equal(t, tt.wantOriginator, upstream.lastReq.Header.Get("originator"))
})
}
}

func TestBuildOpenAIWSHeaders_ForceCodexIdentity(t *testing.T) {
tests := []struct {
name string
enabled bool
wantUserAgent string
wantOriginator string
}{
{
name: "默认关闭时保留最终UA并由终态逻辑自动配对",
wantUserAgent: forceCodexIdentityTUIUserAgent,
wantOriginator: "codex-tui",
},
{
name: "开启时固定Codex CLI UA",
enabled: true,
wantUserAgent: codexCLIUserAgent,
wantOriginator: "codex_cli_rs",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
extra := map[string]any{}
if tt.enabled {
extra["force_codex_identity"] = true
}
c := newForceCodexIdentityGinContext("/v1/responses", forceCodexIdentityInputHeaders())
svc := &OpenAIGatewayService{cfg: &config.Config{}}
account := newForceCodexIdentityOAuthAccount(extra)

headers, _, err := svc.buildOpenAIWSHeaders(context.Background(), c, account, "token", OpenAIWSProtocolDecision{}, false, "", "", "")
require.NoError(t, err)
require.Equal(t, tt.wantUserAgent, headers.Get("user-agent"))
require.Equal(t, tt.wantOriginator, headers.Get("originator"))
})
}
}
Loading
Loading