mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(accounts): preserve migrated identities and restore cumulative usage
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -117,9 +117,6 @@ impl StartAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
parse_provider(&self.provider)?;
|
||||
validate_account_name(&self.name)?;
|
||||
if self.account_id.is_none() && self.name.trim().is_empty() {
|
||||
return Err(WireValidationError::new("name"));
|
||||
}
|
||||
if let Some(account_id) = self.account_id.as_deref() {
|
||||
require_account_id(account_id, "accountId")?;
|
||||
}
|
||||
@@ -141,7 +138,7 @@ impl StartAccountAuthorizationRequest {
|
||||
provider,
|
||||
StartAuthorization {
|
||||
context,
|
||||
name: self.name,
|
||||
name: self.name.trim().to_owned(),
|
||||
reauthorization,
|
||||
outbound_proxy: super::wire::proxy_selection(
|
||||
self.outbound_proxy_id,
|
||||
|
||||
@@ -767,6 +767,24 @@ mod actions {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_accepts_optional_names_with_the_same_text_limits() {
|
||||
for name in ["", " ", "My account"] {
|
||||
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
"provider": "openai", "name": name
|
||||
}))
|
||||
.expect("decode authorization");
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
for name in ["bad\nname".to_owned(), "x".repeat(513)] {
|
||||
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
"provider": "openai", "name": name
|
||||
}))
|
||||
.expect("decode authorization");
|
||||
assert_eq!(request.validate().unwrap_err().field(), "name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
|
||||
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
|
||||
@@ -146,7 +146,6 @@ impl NewProviderAccount {
|
||||
pub fn validate(&self) -> StoreResult<()> {
|
||||
require_nonempty(ENTITY, "id", &self.id)?;
|
||||
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
|
||||
require_nonempty(ENTITY, "name", &self.name)?;
|
||||
if let Some(upstream_user_id) = &self.upstream_user_id {
|
||||
require_nonempty(ENTITY, "upstream_user_id", upstream_user_id)?;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,36 @@ use super::{
|
||||
*,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_persists_an_unnamed_account_with_its_email() {
|
||||
let Some(database) = TestDatabase::create("authorization_unnamed").await else {
|
||||
return;
|
||||
};
|
||||
let store = admin_account_store(&database.pool);
|
||||
let mut prepared = credential("acct_unnamed", "unnamed-user");
|
||||
prepared.name.clear();
|
||||
prepared.email = Some("identity@example.com".into());
|
||||
let (authorization, context) = command(prepared);
|
||||
let result = store
|
||||
.commit_authorization(authorization, &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let account = store
|
||||
.load_account(
|
||||
result.result.account_id.as_str(),
|
||||
AccountRuntimeSnapshot::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(account.account.name, "");
|
||||
assert_eq!(
|
||||
account.account.email.as_deref(),
|
||||
Some("identity@example.com")
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() {
|
||||
let Some(database) = TestDatabase::create("authorization_receipt_race").await else {
|
||||
|
||||
@@ -602,9 +602,6 @@ impl CodexCredentialAdmin {
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
|
||||
let provider = ProviderKind::new(PROVIDER_NAME)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let revision =
|
||||
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let CodexOAuthMetadata {
|
||||
|
||||
@@ -184,8 +184,7 @@ impl CodexPendingAuthorization {
|
||||
if !valid_text(&pending.flow_id)
|
||||
|| !valid_text(&pending.owner_ref)
|
||||
|| !valid_text(&pending.started_request_ref)
|
||||
|| !(valid_text(&pending.name)
|
||||
|| pending.name.is_empty() && pending.reauthorization.is_some())
|
||||
|| !(valid_text(&pending.name) || pending.name.is_empty())
|
||||
|| pending.expires_at <= Utc::now()
|
||||
|| !valid_secret(pending.state.expose_secret())
|
||||
|| !valid_secret(pending.nonce.expose_secret())
|
||||
@@ -590,7 +589,7 @@ impl CodexOAuthAdminService {
|
||||
reauthorization: Option<ProviderAccountId>,
|
||||
installation_id: String,
|
||||
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
|
||||
if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) {
|
||||
if !(valid_text(&name) || name.is_empty()) {
|
||||
return Err(CodexOAuthAdminError::InvalidInput);
|
||||
}
|
||||
let expires_at = Utc::now()
|
||||
|
||||
@@ -156,6 +156,13 @@ fn id_token(payload: serde_json::Value) -> String {
|
||||
|
||||
async fn complete(
|
||||
id_token: String,
|
||||
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
|
||||
complete_with_name(id_token, "new OAuth account").await
|
||||
}
|
||||
|
||||
async fn complete_with_name(
|
||||
id_token: String,
|
||||
name: &str,
|
||||
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
|
||||
let pending_store = Arc::new(PendingStore::default());
|
||||
let service = CodexOAuthAdminService::new(
|
||||
@@ -167,7 +174,13 @@ async fn complete(
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: mutation(),
|
||||
mutation: PendingAuthorizationMutation::new(
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
mutation().owner_binding().clone(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("start OAuth authorization");
|
||||
@@ -463,6 +476,23 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
|
||||
assert_eq!(surface_ids[0], surface_ids[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
|
||||
for name in ["", "My account"] {
|
||||
let (credential, _, _) = complete_with_name(
|
||||
id_token(serde_json::json!({ "email": "identity@example.com" })),
|
||||
name,
|
||||
)
|
||||
.await
|
||||
.expect("complete authorization with optional name");
|
||||
let CompletedCodexOAuthCredential::Create(account) = credential else {
|
||||
panic!("expected account creation");
|
||||
};
|
||||
assert_eq!(account.account.name(), name);
|
||||
assert_eq!(account.account.email(), Some("identity@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() {
|
||||
let (credential, pending_installation_id, surface_stable_id) = complete(id_token(
|
||||
|
||||
@@ -843,7 +843,7 @@ fn validate_scope(scope: &str) -> Result<(), GrokCredentialRepositoryError> {
|
||||
}
|
||||
|
||||
fn validate_name(value: &str) -> Result<(), GrokCredentialRepositoryError> {
|
||||
if value.trim().is_empty()
|
||||
if (!value.is_empty() && value.trim().is_empty())
|
||||
|| value.len() > MAX_NAME_BYTES
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,18 @@ fn repository_rejects_identity_that_cannot_be_sent_as_official_header() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_should_allow_an_empty_local_name() {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let mut input = create_input("unnamed", "subject-unnamed");
|
||||
input.name.clear();
|
||||
seed_input(&store, &input)
|
||||
.await
|
||||
.expect("seed unnamed account");
|
||||
let account = store.get_account(&input.account_id).await.unwrap().unwrap();
|
||||
assert_eq!(account.name(), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_projection_should_return_expiry_without_oauth_secrets() {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
|
||||
@@ -33,3 +33,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and
|
||||
592b80972af3bd8b3527b42da3703ca1106ecaf8fd5273c676189ca28fcbd067 0033_proxy_and_reset_credits.sql
|
||||
9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql
|
||||
bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql
|
||||
d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 默认授权名称不代表用户命名,清空后沿用邮箱展示
|
||||
-- 已有改名审计或没有邮箱的账号保留原值
|
||||
with migrated as (
|
||||
update provider_accounts as account
|
||||
set name = '', updated_at = greatest(now(), account.updated_at)
|
||||
where account.authentication_kind = 'oauth'
|
||||
and nullif(btrim(account.email), '') is not null
|
||||
and (
|
||||
(account.provider_kind = 'openai' and account.name in (
|
||||
'openai OAuth', 'OpenAI 账号', 'OpenAI 賬號', 'OpenAI account',
|
||||
'Codex 账号', 'Codex 賬號', 'Codex account'
|
||||
))
|
||||
or (account.provider_kind = 'xai' and account.name in (
|
||||
'xai OAuth', 'xAI 账号', 'xAI 賬號', 'xAI account'
|
||||
))
|
||||
)
|
||||
and not exists (
|
||||
select 1 from admin_audit_events as audit
|
||||
where audit.entity_kind = 'provider_account'
|
||||
and audit.entity_ref = account.id
|
||||
and 'name' = any(audit.changed_fields)
|
||||
)
|
||||
returning account.id
|
||||
)
|
||||
update runtime_settings
|
||||
set config_revision = config_revision + 1, updated_at = greatest(now(), updated_at)
|
||||
where exists (select 1 from migrated);
|
||||
@@ -665,6 +665,12 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
```
|
||||
|
||||
### 账号名称迁移
|
||||
|
||||
OAuth 账号仍使用默认授权名称(如「Codex 账号」「OpenAI 账号」「xAI 账号」)且保存了邮箱时,
|
||||
升级会清空默认名称,账号列表按邮箱前缀和完整邮箱分两行展示。
|
||||
自定义名称、已有改名审计记录的账号、无邮箱账号及 API Key 名称保留原值
|
||||
|
||||
### 错误保护配置升级
|
||||
|
||||
使用 `accountAutoFreeze*` 运行参数的部署升级时,数据库迁移删除全部旧保护设置,不转换规则或继承参数。
|
||||
|
||||
@@ -1403,6 +1403,9 @@ OAuth start 使用:
|
||||
客户端不得提交 `credentialRevision`、旧 token 身份或其他并发控制字段。complete 请求也不重复提交
|
||||
`accountId`,后端通过 `flowId` 中保存的目标绑定完成授权
|
||||
|
||||
`name` 可为空字符串,表示不设置本地名称;管理端新建授权默认留空,列表使用上游邮箱展示。
|
||||
提供名称时去除首尾空白,长度与字符限制沿用账号名称规则
|
||||
|
||||
流程只允许发起它的管理员身份继续;尚未完成的流程过期后需重新发起。
|
||||
账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId`
|
||||
重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"importTasksCreated": "Import tasks created: {submitted}",
|
||||
"importTaskCreated": "Import task created",
|
||||
"partialSubmission": "Some import tasks were created, retry with the same input to submit the rest",
|
||||
"accountName": "{provider} account",
|
||||
"failedToStartAuthorization": "Failed to start authorization"
|
||||
},
|
||||
"importTasks": {
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"importTasksCreated": "已创建 {submitted} 个导入任务",
|
||||
"importTaskCreated": "导入任务已创建",
|
||||
"partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交",
|
||||
"accountName": "{provider} 账号",
|
||||
"failedToStartAuthorization": "启动授权失败"
|
||||
},
|
||||
"importTasks": {
|
||||
|
||||
@@ -355,7 +355,6 @@
|
||||
"importTasksCreated": "已創建 {submitted} 個導入任務",
|
||||
"importTaskCreated": "導入任務已創建",
|
||||
"partialSubmission": "部分導入任務已創建,保持輸入不變重試可繼續提交",
|
||||
"accountName": "{provider} 賬號",
|
||||
"failedToStartAuthorization": "啓動授權失敗"
|
||||
},
|
||||
"importTasks": {
|
||||
|
||||
@@ -29,17 +29,31 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const displayTitle = computed(() => accountIdentityLabel(props.account))
|
||||
const emailText = computed(() => props.account.email?.trim())
|
||||
const displayTitle = computed(() => {
|
||||
const label = accountIdentityLabel(props.account)
|
||||
return props.account.authenticationKind !== 'api_key' && label === emailText.value
|
||||
? label.split('@')[0]
|
||||
: label
|
||||
})
|
||||
|
||||
const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined)
|
||||
|
||||
const identityTitle = computed(() => {
|
||||
const email = props.account.email?.trim()
|
||||
const email = emailText.value
|
||||
return email && email !== displayTitle.value
|
||||
? `${displayTitle.value}\n${email}`
|
||||
: displayTitle.value
|
||||
})
|
||||
|
||||
const secondaryText = computed(() => props.account.authenticationKind !== 'api_key' && emailText.value !== displayTitle.value
|
||||
? emailText.value
|
||||
: undefined)
|
||||
|
||||
const secondaryClass = computed(() => props.size === 'lg'
|
||||
? 'mt-1 text-z-sm text-z-text-secondary'
|
||||
: 'mt-0.5 font-mono text-z-xs text-z-text-quaternary')
|
||||
|
||||
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
|
||||
|
||||
const avatarSizeClass = computed(() =>
|
||||
@@ -89,6 +103,9 @@ const avatarToneClass = computed(() => {
|
||||
<AccountPlanBadge v-if="showPlan" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
|
||||
</div>
|
||||
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-0.5" />
|
||||
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
|
||||
{{ secondaryText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,8 @@ const emit = defineEmits<{ showConcurrency: [] }>()
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
const localUsageOnly = computed(() => props.account.authenticationKind === 'api_key' && !props.account.upstream?.discovered)
|
||||
|
||||
// 互联列表只展示此 Key 的消费限额,池内各账号的百分比不能代表整个上游
|
||||
const quotaWindows = computed(() => props.account.upstream?.discovered
|
||||
? props.account.quota.windows.filter(window => window.amount)
|
||||
@@ -57,11 +59,11 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
|
||||
<template>
|
||||
<div class="box-border grid min-h-16.5 w-full min-w-0 content-center gap-1.5 py-1.5">
|
||||
<div
|
||||
v-if="hasTokenUsage || currentUsageWindow"
|
||||
v-if="localUsageOnly || hasTokenUsage || currentUsageWindow"
|
||||
class="flex min-w-0 items-baseline justify-between gap-2 leading-none"
|
||||
>
|
||||
<span
|
||||
v-if="hasTokenUsage"
|
||||
v-if="localUsageOnly || hasTokenUsage"
|
||||
class="flex min-w-0 items-baseline gap-1 font-mono tabular-nums"
|
||||
:title="summaryValue.title"
|
||||
>
|
||||
@@ -84,7 +86,14 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-end gap-2">
|
||||
<div v-if="localUsageOnly" class="grid min-w-0 gap-1.5">
|
||||
<div class="flex min-w-0 items-start justify-between gap-2 leading-3">
|
||||
<span class="truncate text-[10px] font-bold text-z-text-quaternary">{{ usageWindowLabels[account.usage.windowKind] }}</span>
|
||||
<AccountCapacityIndicator :capacity="account.capacity" @show-details="emit('showConcurrency')" />
|
||||
</div>
|
||||
<div class="h-1 w-full rounded-full bg-z-border-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 items-end gap-2">
|
||||
<!-- 共用列轨道为容量留位,进度条仍铺满整行,容量标记独立于额度弹层 -->
|
||||
<div class="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] gap-x-2">
|
||||
<AccountQuotaSummaryEntry
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { t } from '@/i18n'
|
||||
import { notification as ZNotification } from '@/utils/notification'
|
||||
import { errorMessage, generateRequestId } from '@/utils/operation'
|
||||
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
|
||||
import { isSupportedProvider } from '@/utils/providers'
|
||||
import { accountCreateProvider, accountCreateSourceKey, accountImportModes, accountImportSettings, emptyAccountCreateForm } from '../utils/accountCreate'
|
||||
import { accountImportBatches, accountImportDocuments, mixedImportDocuments } from '../utils/accountImport'
|
||||
import { emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
|
||||
@@ -134,9 +134,7 @@ export function useAccountOnboarding(options: {
|
||||
await authorization.start({
|
||||
start: {
|
||||
provider,
|
||||
name: account?.name || account?.email || t('accounts.onboarding.accountName', {
|
||||
provider: formatProviderLabel(provider),
|
||||
}),
|
||||
name: account?.name ?? '',
|
||||
accountId: account?.id,
|
||||
outboundProxyId: account
|
||||
? undefined
|
||||
|
||||
Reference in New Issue
Block a user