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
75 changes: 75 additions & 0 deletions backend/internal/service/admin_account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package service

import (
"context"
"testing"

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

// TestCreateAccountPreservesSuppliedUserAgent 覆盖账号创建流程不主动生成/篡改
// user_agent:管理员显式提供的值原样落库,未提供则该键不存在(不写入任何默认值,
// 出站阶段回退全局设置 openai_codex_user_agent 或内置常量)。
func TestCreateAccountPreservesSuppliedUserAgent(t *testing.T) {
repo := &upstreamBillingProbeAccountRepo{}
svc := &adminServiceImpl{accountRepo: repo}

const adminUA = "codex-tui/0.1.0 (Test OS; test) test-term"
created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Name: "codex-oauth-manual-ua",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
SkipDefaultGroupBind: true,
Credentials: map[string]any{"user_agent": adminUA},
})
require.NoError(t, err)
require.Equal(t, adminUA, created.GetOpenAIUserAgent())

withoutUA, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Name: "codex-oauth-no-ua",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
SkipDefaultGroupBind: true,
})
require.NoError(t, err)
require.Empty(t, withoutUA.GetOpenAIUserAgent(),
"未手填 user_agent 时创建流程不应生成任何默认值,出站阶段统一走全局设置")
}

// TestUpdateAccountUserAgentOverrideAndClear 覆盖账号编辑界面的自定义 User-Agent 覆盖:
// 提交 user_agent 会覆盖现有值;再次提交不含该键、但含其它非敏感字段的完整 credentials
// 会让该键被移除(MergePreservingSensitiveCreds 对非敏感键"完全由 incoming 决定"的既有语义)。
//
// 注意:`UpdateAccount` 对 `len(input.Credentials) == 0` 直接跳过整个凭据合并分支
// (admin_account.go 的 `else if len(input.Credentials) > 0` 守卫),提交空对象不会清空任何
// 凭据。真实账号编辑场景下 credentials 里通常还有 `chatgpt_account_id` 等非敏感字段,所以
// "清空 = 提交不含该键但非空的 credentials"这个约定在实践中成立;这里用一个占位非敏感字段
// 模拟这种真实情况,而不是提交空对象。
func TestUpdateAccountUserAgentOverrideAndClear(t *testing.T) {
accountID := int64(301)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: {
ID: accountID,
Name: "codex-oauth-edit",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"chatgpt_account_id": "acct-123"},
},
}}
svc := &adminServiceImpl{accountRepo: repo}

const manualUA = "codex-tui/0.2.0 (Windows 11; x86_64) conhost"
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Credentials: map[string]any{"chatgpt_account_id": "acct-123", "user_agent": manualUA},
})
require.NoError(t, err)
require.Equal(t, manualUA, updated.GetOpenAIUserAgent())

cleared, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Credentials: map[string]any{"chatgpt_account_id": "acct-123"},
})
require.NoError(t, err)
require.Empty(t, cleared.GetOpenAIUserAgent(),
"提交不含 user_agent 键、但含其它非敏感字段的完整 credentials 应让该键被移除,回退到未手填状态")
}
37 changes: 34 additions & 3 deletions frontend/src/components/account/EditAccountModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,24 @@
</div>
</div>

<!-- 自定义出站 User-Agent(Codex 指纹),仅 OAuth 非影子账号 -->
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label mb-0">{{ t('admin.accounts.openai.customUserAgent') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.customUserAgentDesc') }}
</p>
<input
v-model="editUserAgent"
type="text"
class="input mt-2"
data-testid="edit-user-agent-input"
:placeholder="t('admin.accounts.openai.customUserAgentPlaceholder')"
/>
</div>

<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
Expand Down Expand Up @@ -2870,8 +2888,10 @@ import {
applyHeaderOverride,
applyInterceptWarmup,
applyPlanType,
applyUserAgent,
buildPlanTypeOptions,
readPlanType,
readUserAgent,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
splitHeaderOverridesObject,
Expand Down Expand Up @@ -3224,6 +3244,9 @@ const openaiFlattenNamespacesEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
// 账号级自定义出站 User-Agent,存于 credentials.user_agent;'' 表示清空,
// 回退到指纹池自动分配结果或全局默认逻辑(不在此处重新触发分配)。
const editUserAgent = ref<string>('')
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
Expand Down Expand Up @@ -3703,6 +3726,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiFlattenNamespacesEnabled.value = false
openAILongContextBillingEnabled.value = false
editPlanType.value = ''
editUserAgent.value = ''
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
Expand All @@ -3726,6 +3750,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editPlanType.value = newAccount.type === 'oauth'
? readPlanType(newAccount.credentials as Record<string, unknown> | undefined)
: ''
// 自定义 User-Agent 同样只对出站身份走 Codex 协议的 OAuth 账号有意义
editUserAgent.value = newAccount.type === 'oauth'
? readUserAgent(newAccount.credentials as Record<string, unknown> | undefined)
: ''
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
if (newAccount.type === 'apikey') {
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
Expand Down Expand Up @@ -4934,12 +4962,15 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}

// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)+ 自定义出站 User-Agent。
// 仅 OAuth 非影子账号:影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅
// 调度语义、也不走 Codex 出站身份重建。清空 User-Agent 输入框只是删除该键,不会重新触发
// 指纹池分配(分配只发生在账号创建时)。
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
updatePayload.credentials = applyPlanType({ ...currentCredentials }, editPlanType.value)
const nextCredentials = applyPlanType({ ...currentCredentials }, editPlanType.value)
updatePayload.credentials = applyUserAgent(nextCredentials, editUserAgent.value)
}

// Antigravity: persist model mapping to credentials (applies to all antigravity types)
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/account/__tests__/EditAccountModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1422,4 +1422,54 @@ describe('EditAccountModal OpenAI 自动使用重置卡', () => {
expect(updateAccountMock).not.toHaveBeenCalled()
wrapper.unmount()
})

describe('自定义 User-Agent(Codex 出站指纹)', () => {
it('OpenAI OAuth 非影子账号渲染自定义 User-Agent 输入框', () => {
const wrapper = mountModal(buildOpenAIOAuthParentAccount())
expect(wrapper.find('[data-testid="edit-user-agent-input"]').exists()).toBe(true)
wrapper.unmount()
})

it('影子账号不渲染自定义 User-Agent 输入框', () => {
const wrapper = mountModal(buildOpenAISparkShadowAccount())
expect(wrapper.find('[data-testid="edit-user-agent-input"]').exists()).toBe(false)
wrapper.unmount()
})

it('填入自定义 User-Agent 后保存,提交体里 credentials.user_agent 等于填写值', async () => {
const account = buildOpenAIOAuthParentAccount()
updateAccountMock.mockReset().mockResolvedValue(account)
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })

const wrapper = mountModal(account)
await wrapper
.get('[data-testid="edit-user-agent-input"]')
.setValue('codex-tui/0.2.0 (Windows 11; x86_64) conhost')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')

expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
user_agent: 'codex-tui/0.2.0 (Windows 11; x86_64) conhost'
})
wrapper.unmount()
})

it('清空输入框后保存,提交体的 credentials 里不包含 user_agent 键', async () => {
const account = buildOpenAIOAuthParentAccount()
account.credentials = { ...account.credentials, user_agent: 'codex-tui/0.1.0 (Old OS; x86_64) old-term' }
updateAccountMock.mockReset().mockResolvedValue(account)
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })

const wrapper = mountModal(account)
expect(wrapper.get<HTMLInputElement>('[data-testid="edit-user-agent-input"]').element.value).toBe(
'codex-tui/0.1.0 (Old OS; x86_64) old-term'
)
await wrapper.get('[data-testid="edit-user-agent-input"]').setValue('')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')

expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty('user_agent')
wrapper.unmount()
})
})
})
25 changes: 25 additions & 0 deletions frontend/src/components/account/credentialsBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,28 @@ export function applyPlanType(
}
return credentials
}

/**
* 从凭据里读取自定义 User-Agent,仅接受字符串(脏数据一律视为空)。
*/
export function readUserAgent(credentials: Record<string, unknown> | undefined | null): string {
const v = credentials?.user_agent
return typeof v === 'string' ? v : ''
}

/**
* 把手动填写的自定义 User-Agent 写入凭据:非空则设置,空则删除该键(清空回退到指纹池
* 自动分配或全局默认逻辑)。直接修改传入对象并返回。
*/
export function applyUserAgent(
credentials: Record<string, unknown>,
userAgent: string
): Record<string, unknown> {
const ua = (userAgent || '').trim()
if (ua) {
credentials.user_agent = ua
} else {
delete credentials.user_agent
}
return credentials
}
4 changes: 4 additions & 0 deletions frontend/src/i18n/locales/en/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,10 @@ export default {
planTypeDesc:
"Manually correct this account's ChatGPT plan tier (Plus / Pro / Free). Note: a token refresh near expiry or a 429 rate-limit response will auto-overwrite this with the real tier.",
planTypeClear: 'Clear (auto-detect)',
customUserAgent: 'Custom User-Agent',
customUserAgentDesc:
"Manually set the full outbound Codex User-Agent for this account — it only contributes the client name / OS / architecture / terminal fingerprint. The version segment and originator are still rebuilt to the currently effective version, so an old version typed here won't get stuck. Leave blank to fall back to the fingerprint assigned automatically at creation, or the global default.",
customUserAgentPlaceholder: 'e.g. codex-tui/0.146.0 (macOS 15.1; arm64) iTerm.app',
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/locales/zh/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,10 @@ export default {
planType: '订阅档位(手动覆盖)',
planTypeDesc: '手动纠正本账号的 ChatGPT 订阅档位(Plus / Pro / Free)。注意:令牌临期刷新或命中 429 限流时,会用真实档位自动覆盖此处设置。',
planTypeClear: '清空(自动识别)',
customUserAgent: '自定义 User-Agent',
customUserAgentDesc:
'手动指定本账号出站请求的完整 Codex User-Agent,仅贡献客户端名 / OS / 架构 / 终端指纹;版本号与 originator 仍由系统按当前生效版本重建,不会被这里填的旧版本卡住。留空则回退到创建时按账号自动分配的指纹或全局默认逻辑。',
customUserAgentPlaceholder: '例如 codex-tui/0.146.0 (macOS 15.1; arm64) iTerm.app',
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',
Expand Down
Loading