Compare commits

...
6 Commits
Author SHA1 Message Date
ZyyandCodex 197e9e517a chore: bump version to 3.43.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 18:12:22 +08:00
Zyy e770c94a9e fix(openai): preserve Codex quota after image generation 2026-11-08 18:04:45 +08:00
ce620f1b53 feat(release): add Intel macOS downloads (#641)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 17:30:22 +08:00
ZyyandCodex 290aecb7a4 fix(admin): keep page scrolling on the compositor
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 14:05:10 +08:00
ZyyandCodex 1a2a4a56ae fix(openai): update account identity on OAuth reauthorization
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:55:30 +08:00
github-actions[bot] 6dd0da1fbd chore(release): update release channels to 3.43.0 2026-11-07 19:38:40 +00:00
29 changed files with 511 additions and 235 deletions
+2 -1
View File
@@ -40,7 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "linux",
runtime_architecture: "aarch64",
},
Platform {
release_os: "darwin",
release_architecture: "amd64",
target: "x86_64-apple-darwin",
runtime_os: "macos",
runtime_architecture: "x86_64",
},
Platform {
release_os: "darwin",
release_architecture: "arm64",
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
}
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
let (release_os, release_architecture, target) = release_platform();
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
let artifacts = [
("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "arm64", "aarch64-unknown-linux-gnu"),
("darwin", "amd64", "x86_64-apple-darwin"),
("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "arm64", "aarch64-pc-windows-msvc"),
]
.map(|(release_os, release_architecture, target)| {
let os = if release_os == "darwin" {
"macos"
} else {
release_os
};
let (architecture, _) = target.split_once('-').unwrap();
serde_json::json!({
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": os,
"architecture": architecture,
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
"sha256": format!("{marker:01x}").repeat(64),
})
});
serde_json::json!({
"id": id,
"version": "1.0.0",
"publisher": "test",
"stability": "experimental",
"artifacts": [{
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": std::env::consts::OS,
"architecture": std::env::consts::ARCH,
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
"sha256": format!("{marker:01x}").repeat(64),
}]
"artifacts": artifacts,
})
}
fn release_platform() -> (&'static str, &'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
platform => panic!("unsupported test platform: {platform:?}"),
}
}
@@ -328,7 +328,7 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
limit_ids.insert(active_limit.clone());
}
limit_ids.insert("codex".to_string());
let limits = limit_ids
let mut limits = limit_ids
.into_iter()
.filter_map(|limit_id| {
let prefix = format!("x-{}", limit_id.replace('_', "-"));
@@ -336,6 +336,16 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
.map(|details| (limit_id, details))
})
.collect::<BTreeMap<_, _>>();
// 生图等响应只通过 active-limit 标明通用 x-codex 窗口的归属
// 有独立具名头时保留各桶原始事实,避免把真正的 Codex 窗口一起迁移
if let Some(active_limit) = active_limit.as_ref()
&& active_limit != "codex"
&& !limits.contains_key(active_limit)
&& let Some(mut details) = limits.remove("codex")
{
details.limit_id.clone_from(active_limit);
limits.insert(active_limit.clone(), details);
}
let credits = parse_credits_from_lookup(&normalized);
let plan_type = lookup_non_empty(&normalized, "x-codex-plan-type");
let promo_message = lookup_non_empty(&normalized, "x-codex-promo-message");
@@ -540,6 +540,7 @@ fn parse_rate_limit_headers_should_extract_primary_and_secondary_windows() {
#[test]
fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata() {
let headers = vec![
("x-codex-primary-used-percent".to_owned(), "95".to_owned()),
(
"x-codex-other-primary-used-percent".to_owned(),
"63.5".to_owned(),
@@ -556,6 +557,13 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
];
let parsed = parse_rate_limit_headers(&headers).expect("rate-limit headers");
assert_eq!(
parsed.limits["codex"]
.primary
.expect("core window")
.used_percent,
95.0
);
let details = parsed.limits.get("codex_other").expect("dynamic limit");
let credits = parsed.credits.expect("credits");
@@ -579,6 +587,38 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
);
}
#[test]
fn parse_rate_limit_headers_should_assign_generic_windows_to_the_active_limit() {
let headers = [
("x-codex-active-limit", "imagegen_premium"),
("x-codex-primary-used-percent", "0"),
("x-codex-primary-window-minutes", "1440"),
("x-codex-primary-reset-at", "1900000000"),
("x-codex-secondary-used-percent", "0"),
("x-codex-secondary-window-minutes", "0"),
("x-codex-secondary-reset-at", ""),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
let parsed = parse_rate_limit_headers(&headers).expect("image rate-limit headers");
let image = parsed.limits.get("imagegen_premium").expect("image limit");
assert_eq!(image.limit_id, "imagegen_premium");
assert_eq!(image.key_source, RateLimitKeySource::LimitId);
assert_eq!(
image.primary,
Some(RateLimitWindow {
used_percent: 0.0,
window_minutes: Some(1440),
reset_at: Some(1_900_000_000),
})
);
assert!(!parsed.limits.contains_key("codex"));
assert_eq!(
parse_rate_limit_headers(&rate_limits_to_header_pairs(&parsed)),
Some(parsed)
);
}
#[test]
fn parse_rate_limit_headers_should_preserve_a_code_review_window() {
let headers = vec![
@@ -138,10 +138,13 @@ async fn commit_authorization_in_transaction(
context,
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize,
)?;
let result =
repository::rotate_provider_account_admin_in_transaction(transaction, prepared)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
let result = repository::rotate_provider_account_admin_in_transaction(
transaction,
prepared,
true,
)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
admin_adapter::rotation_result(result, account_id.clone())?
}
_ => return Err(invalid_receipt()),
@@ -520,7 +520,8 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
postgres_unavailable("begin provider account admin rotation", source)
})?;
let account_id = command.credential.account_id.clone();
let result = rotate_provider_account_admin_in_transaction(&mut transaction, command).await;
let result =
rotate_provider_account_admin_in_transaction(&mut transaction, command, false).await;
let rotation =
finish_admin_transaction(transaction, result, "provider account admin rotation")
.await?;
@@ -849,6 +850,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
profile: &UpdateProviderAccount,
replacement_identity: Option<&ProviderAccountIdentity>,
update: &ProviderCredentialUpdate,
preserve_name: bool,
) -> StoreResult<Revision> {
let replace_identity = replacement_identity.is_some();
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
@@ -857,11 +859,15 @@ pub(crate) async fn rotate_provider_account_in_transaction(
// 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = case when $14 then name else $4 end,
set name = case when $14 or $16 then name else $4 end,
email = case when $14 then email else $5 end,
plan_type = case when $14 then plan_type else $6 end,
provider_quota_json = case when $14 then provider_quota_json else null end,
quota_observed_at = case when $14 then quota_observed_at else null end,
quota_access_state = case when $11 then 'unknown' else quota_access_state end,
quota_evidence = case when $11 then null else quota_evidence end,
quota_access_observed_at = case when $11 then null else quota_access_observed_at end,
quota_reset_at = case when $11 then null else quota_reset_at end,
provider_credentials_json = $7,
credential_revision = credential_revision + 1,
has_refresh_token = $8,
@@ -900,6 +906,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
.bind(upstream_account_id)
.bind(update.preserve_profile)
.bind(update.preserve_credential_state)
.bind(preserve_name)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
@@ -1166,6 +1173,7 @@ pub(super) async fn import_provider_accounts_in_transaction(
pub(super) async fn rotate_provider_account_admin_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
command: RotateProviderAccount,
preserve_name: bool,
) -> StoreResult<ProviderAccountAdminRotation> {
command.scope.validate()?;
require_nonempty(ENTITY, "account_id", &command.profile.id)?;
@@ -1194,6 +1202,7 @@ pub(super) async fn rotate_provider_account_admin_in_transaction(
&command.profile,
command.replacement_identity.as_ref(),
&command.credential,
preserve_name,
)
.await?;
// 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新
@@ -310,11 +310,14 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
account_id: id.clone(),
provider_kind: ProviderKind::new("openai").unwrap(),
expected_credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
replacement_identity: None,
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
name: "remote profile".into(),
email: None,
plan_type: None,
preserve_profile: true,
email: Some("new@example.com".into()),
plan_type: Some("plus".into()),
preserve_profile: false,
preserve_credential_state: false,
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"access_token":"rotated-test-only"})
@@ -347,15 +350,17 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
.commit_authorization(command.clone(), &context)
.await
.unwrap();
let account = store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account;
assert_eq!(account.name, "renamed team");
assert_eq!(account.email.as_deref(), Some("new@example.com"));
assert_eq!(
store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account
.name,
"renamed team"
account.upstream_account_id.as_deref(),
Some("new-workspace")
);
store
.update_account(
@@ -3007,7 +3007,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
}
#[tokio::test]
async fn verified_credential_rotation_preserves_quota_exhaustion() {
async fn quota_exhaustion_survives_rotation_only_for_the_same_upstream_identity() {
let Some(database) = TestDatabase::create("provider_account_rotation_quota").await else {
return;
};
@@ -3043,7 +3043,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
scope: scope.clone(),
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: None,
credential: credential_update("acct_rotation_quota", 1, "reauthorized-secret"),
@@ -3064,6 +3064,28 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
.expect("load exhausted account after credential rotation");
assert_eq!(quota_access_state, "exhausted".to_owned());
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
credential: credential_update("acct_rotation_quota", 2, "new-account-secret"),
audit: audit("audit_quota_rebind", "reauthorize", "acct_rotation_quota"),
})
.await
.unwrap();
let account = repository
.get_account(&ProviderAccountId::new("acct_rotation_quota").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(account.upstream_account_id(), Some("new-workspace"));
assert_eq!(account.quota(), QuotaState::unknown());
database.close().await;
}
@@ -661,7 +661,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.prepare_oauth_rotation(current, secret, None, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -31,9 +31,8 @@ use super::token_client::{
OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher,
};
use super::types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, ResponsesTransport,
parse_access_token_expiration, parse_chatgpt_jwt_claims,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData, CodexOAuthMetadata,
CodexOAuthSecret, ResponsesTransport, parse_access_token_expiration, parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
@@ -272,26 +271,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
pub secret: CodexOAuthSecret,
pub verified_account: CodexAccountProfile,
pub next_refresh_at: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for RotateManagedCodexCredential {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RotateManagedCodexCredential")
.field("current", &self.current)
.field("secret", &"<redacted>")
.field("verified_account", &self.verified_account)
.field("next_refresh_at", &self.next_refresh_at)
.finish()
}
}
/// Provider 验证后的 rotation;App 只做 Core -> Store command 的机械映射
pub struct PreparedCodexCredentialRotation {
pub profile: ProviderAccountUpdate,
@@ -725,21 +704,12 @@ impl CodexCredentialAdmin {
})
}
pub fn prepare_rotation(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, false)
}
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料
///
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
/// token 重新执行身份验证
pub(crate) fn prepare_refreshed_oauth_rotation(
/// 重新授权提供本次登录的身份;普通令牌续期只轮换凭据
pub(crate) fn prepare_oauth_rotation(
&self,
current: LoadedCredential,
secret: CodexOAuthSecret,
metadata: Option<CodexOAuthMetadata>,
access_token_expires_at: Option<DateTime<Utc>>,
next_refresh_at: Option<DateTime<Utc>>,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
@@ -753,6 +723,32 @@ impl CodexCredentialAdmin {
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let mut profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let reauthorization = metadata.is_some();
let mut replacement_identity = None;
if let Some(metadata) = metadata {
let user_id = metadata
.chatgpt_user_id
.filter(|id| !id.trim().is_empty())
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if current.account.upstream_user_id() != Some(user_id.as_str())
|| current.account.upstream_account_id() != metadata.chatgpt_account_id.as_deref()
{
replacement_identity = Some(ProviderAccountIdentity::new(
user_id,
metadata.chatgpt_account_id,
));
oauth.cookies.clear();
oauth.principal = None;
}
profile.email = metadata.email;
profile.plan_type = metadata.chatgpt_plan_type;
}
oauth.access_token = secret.access_token.expose_secret().to_owned();
oauth.refresh_token = secret
.refresh_token
@@ -764,12 +760,6 @@ impl CodexCredentialAdmin {
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let credential = CredentialCasUpdate::new(
current.account.id().clone(),
current.account.revision(),
@@ -779,76 +769,14 @@ impl CodexCredentialAdmin {
optional_time(access_token_expires_at),
optional_time(next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
.preserving_profile();
Ok(PreparedCodexCredentialRotation {
profile,
credential,
replacement_identity: None,
refresh_guards: None,
})
}
fn prepare_oauth_rotation(
&self,
input: RotateManagedCodexCredential,
replace_identity: bool,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
let access_token_expires_at = input.verified_account.access_token_expires_at;
let mut data = CodexCredentialCodec::decode_complete(&input.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if input.current.account.provider().as_str() != PROVIDER_NAME
|| input.current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
let replacement_identity = replace_identity.then(|| {
ProviderAccountIdentity::new(
input.verified_account.chatgpt_user_id.clone(),
Some(input.verified_account.chatgpt_account_id.clone()),
)
});
if replace_identity {
oauth.principal = Some(CodexCredentialPrincipal {
oauth_subject: input.verified_account.oauth_subject.clone(),
poid: input.verified_account.poid.clone(),
});
}
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
oauth.refresh_token = input
.secret
.refresh_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
oauth.id_token = input
.secret
.id_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: input.current.account.id().clone(),
name: input.current.account.name().to_owned(),
email: input.verified_account.email,
plan_type: input.verified_account.plan_type,
};
let credential = CredentialCasUpdate::new(
input.current.account.id().clone(),
input.current.account.revision(),
profile.clone(),
credential,
input.secret.refresh_token.is_some(),
optional_time(access_token_expires_at),
optional_time(input.next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
Ok(PreparedCodexCredentialRotation {
profile,
credential,
credential: if reauthorization {
credential
} else {
credential.preserving_profile()
},
replacement_identity,
refresh_guards: None,
})
@@ -1002,9 +930,10 @@ impl CodexCredentialAdminService {
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before
let next_refresh_at = None;
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
let mut prepared = CodexCredentialAdmin.prepare_oauth_rotation(
current,
secret,
None,
access_token_expires_at,
next_refresh_at,
)?;
@@ -1095,20 +1024,7 @@ impl CodexCredentialAdminService {
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata = parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
}
id_metadata.with_access_token_fallback(&secret.access_token)
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
@@ -36,7 +36,7 @@ pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
PreparedCodexAccountImport, PreparedCodexCredentialRotation,
PreparedCodexCredentialRotationGuard, RotateManagedCodexCredential,
PreparedCodexCredentialRotationGuard,
};
pub use catalog::{
CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService,
@@ -72,6 +72,7 @@ pub use selector::{
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub(crate) use types::oauth_identity_key;
pub use types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
@@ -516,16 +516,10 @@ impl CodexOAuthAdminService {
} else {
None
};
let fallback_refresh_token = current
.as_ref()
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token) = self
.exchange_pending(
&pending,
callback_url,
fallback_refresh_token,
current
.as_ref()
.map_or(pending.mutation.outbound_proxy(), |current| {
@@ -537,14 +531,16 @@ impl CodexOAuthAdminService {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
.map_err(|_| CodexOAuthAdminError::TokenRejected)?
.with_access_token_fallback(&secret.access_token);
secret.id_token = Some(id_token);
let credential = if let Some(current) = current {
CompletedCodexOAuthCredential::Reauthorize(Box::new(
self.credentials
.prepare_refreshed_oauth_rotation(
.prepare_oauth_rotation(
current,
secret,
Some(metadata),
access_token_expires_at,
None,
)
@@ -680,7 +676,6 @@ impl CodexOAuthAdminService {
&self,
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
@@ -701,10 +696,7 @@ impl CodexOAuthAdminService {
)
.await
.map_err(CodexOAuthAdminError::Exchange)?;
let mut secret = tokens.secret;
if secret.refresh_token.is_none() {
secret.refresh_token = fallback_refresh_token;
}
let secret = tokens.secret;
record_oauth_recovery(
CodexOAuthRecoveryOperation::AuthorizationCode,
pending
@@ -720,17 +712,6 @@ impl CodexOAuthAdminService {
}
}
fn current_oauth_refresh_token(
current: &LoadedCredential,
) -> Result<Option<SecretString>, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
Ok(runtime
.authentication
.oauth()
.and_then(|oauth| oauth.refresh_token.clone()))
}
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
@@ -110,7 +110,7 @@ pub struct CodexCredentialProfileService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
avatar_sources: Mutex<HashMap<ProviderAccountId, CachedProfileAvatarSource>>,
avatar_sources: Mutex<HashMap<String, CachedProfileAvatarSource>>,
}
impl CodexCredentialProfileService {
@@ -183,23 +183,36 @@ impl CodexCredentialProfileService {
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
self.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await
}
async fn fetch_profile_statistics(
&self,
authorization: &SecretString,
upstream_account_id: Option<&str>,
account: &gateway_core::account::ProviderAccount,
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
let statistics = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.for_account(&account)
.for_account(account)
.map_err(map_client_error)?
.fetch_profile_statistics(CodexRequestContext::auxiliary(
authorization.expose_secret(),
upstream_account_id.as_deref(),
upstream_account_id,
&request_id,
None,
))
.await
.map_err(map_client_error)?;
self.remember_avatar_source(account_id, statistics.image_url.as_deref());
self.remember_avatar_source(
super::oauth_identity_key(account),
statistics.image_url.as_deref(),
);
Ok(statistics)
}
@@ -253,16 +266,16 @@ impl CodexCredentialProfileService {
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileAvatar, CodexProfileAvatarError> {
let source = match self.cached_avatar_source(account_id) {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let source = match self.cached_avatar_source(&super::oauth_identity_key(&account)) {
Some(source) => source,
None => self
.profile_statistics(account_id)
.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await?
.image_url
.ok_or(CodexProfileAvatarError::Missing)?,
};
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let http = if account.outbound_proxy().is_some() {
crate::transport::client::build_account_http_client(
account.id().as_str(),
@@ -288,17 +301,17 @@ impl CodexCredentialProfileService {
.map_err(map_avatar_fetch_error)
}
fn cached_avatar_source(&self, account_id: &ProviderAccountId) -> Option<String> {
fn cached_avatar_source(&self, identity: &str) -> Option<String> {
let now = Instant::now();
let mut sources = self
.avatar_sources
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sources.retain(|_, source| source.expires_at > now);
sources.get(account_id).map(|source| source.source.clone())
sources.get(identity).map(|source| source.source.clone())
}
fn remember_avatar_source(&self, account_id: &ProviderAccountId, source: Option<&str>) {
fn remember_avatar_source(&self, identity: String, source: Option<&str>) {
let now = Instant::now();
let mut sources = self
.avatar_sources
@@ -308,7 +321,7 @@ impl CodexCredentialProfileService {
match source {
Some(source) => {
sources.insert(
account_id.clone(),
identity,
CachedProfileAvatarSource {
source: source.to_owned(),
expires_at: now + PROFILE_AVATAR_SOURCE_TTL,
@@ -316,7 +329,7 @@ impl CodexCredentialProfileService {
);
}
None => {
sources.remove(account_id);
sources.remove(&identity);
}
}
}
@@ -4,10 +4,26 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use url::Url;
/// 复用上游会话与账号资料时绑定本地记录和真实 OAuth 身份,正常续期不改变归属
pub(crate) fn oauth_identity_key(account: &gateway_core::account::ProviderAccount) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for part in [
account.id().as_str(),
account.upstream_user_id().unwrap_or_default(),
account.upstream_account_id().unwrap_or_default(),
] {
hash.update((part.len() as u64).to_le_bytes());
hash.update(part.as_bytes());
}
hex::encode(hash.finalize())
}
/// OAuth AT/RT/ID Token;`Debug` 永不输出明文
#[derive(Clone)]
pub struct CodexOAuthSecret {
@@ -53,6 +69,18 @@ pub(crate) struct CodexOAuthMetadata {
pub(crate) chatgpt_account_id: Option<String>,
}
impl CodexOAuthMetadata {
pub(crate) fn with_access_token_fallback(self, access_token: &SecretString) -> Self {
let fallback = parse_chatgpt_jwt_claims(access_token.expose_secret()).unwrap_or_default();
Self {
email: self.email.or(fallback.email),
chatgpt_plan_type: self.chatgpt_plan_type.or(fallback.chatgpt_plan_type),
chatgpt_user_id: self.chatgpt_user_id.or(fallback.chatgpt_user_id),
chatgpt_account_id: self.chatgpt_account_id.or(fallback.chatgpt_account_id),
}
}
}
impl fmt::Debug for CodexOAuthMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -429,6 +429,8 @@ pub(super) struct OpenAiSessionState {
pub(super) account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
#[serde(default)]
pub(super) turn_state: Option<String>,
@@ -450,6 +452,7 @@ pub(super) enum OpenAiContinuationScope {
pub(super) struct OpenAiSessionCapture {
pub(super) account_id: String,
pub(super) credential_revision: Option<u64>,
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
pub(super) turn_state: Option<String>,
pub(super) client_turn_id: Option<String>,
@@ -498,6 +501,7 @@ fn encode_openai_session_capture(
encode_openai_session_state(OpenAiSessionState {
account_id: capture.account_id.clone(),
credential_revision: capture.credential_revision,
oauth_identity: capture.oauth_identity.clone(),
conversation_id: capture.conversation_id.clone(),
turn_state: capture.turn_state.clone(),
client_turn_id: capture.client_turn_id.clone(),
@@ -1164,7 +1168,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
if let Some(affinity) = &routing_affinity {
for fact in events.iter().flat_map(ProviderEvent::canonical_facts) {
if let GatewayEvent::Started(meta) = fact {
guardian_routing.remember(context.client_api_key_ref(), active_account.id(), meta.response_id(), affinity);
guardian_routing.remember(context.client_api_key_ref(), &active_account, meta.response_id(), affinity);
}
}
}
@@ -6,7 +6,7 @@ use std::{
time::{Duration, Instant},
};
use gateway_core::{account::ProviderAccountId, policy::ClientApiKeyId};
use gateway_core::{account::ProviderAccount, policy::ClientApiKeyId};
use sha2::{Digest, Sha256};
use crate::transport::routing_affinity::ChatGptRoutingAffinity;
@@ -29,7 +29,7 @@ impl GuardianRouting {
pub(super) fn get(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> Option<ChatGptRoutingAffinity> {
let key = parent_key(client_key, account, response_id);
@@ -48,7 +48,7 @@ impl GuardianRouting {
pub(super) fn remember(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
affinity: &ChatGptRoutingAffinity,
) {
@@ -83,11 +83,12 @@ impl GuardianRouting {
fn parent_key(
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> [u8; 32] {
let mut hash = Sha256::new();
for part in [client_key.as_str(), account.as_str(), response_id] {
let identity = crate::credential::oauth_identity_key(account);
for part in [client_key.as_str(), &identity, response_id] {
hash.update(part.len().to_le_bytes());
hash.update(part.as_bytes());
}
@@ -888,15 +888,24 @@ impl CodexProvider {
}
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let oauth_identity = matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
)
.then(|| crate::credential::oauth_identity_key(lease.account()));
let native_owner = context.continuation().and_then(ContinuationBinding::pinned);
let state_owner_cross_account = native_owner
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|| previous_session.as_ref().is_some_and(|state| {
state.account_id != lease.account_id().as_str()
|| state
.oauth_identity
.as_ref()
.is_some_and(|identity| Some(identity) != oauth_identity.as_ref())
});
let has_explicit_state_owner = context.account_state_owner().is_some()
|| previous_session.is_some()
|| native_owner.is_some();
@@ -970,11 +979,7 @@ impl CodexProvider {
.flatten()
.and_then(|response_id| {
self.guardian_routing
.get(
context.client_api_key_ref(),
lease.account_id(),
response_id,
)
.get(context.client_api_key_ref(), lease.account(), response_id)
.map(|affinity| (response_id.to_owned(), affinity))
});
let client = self.client_for_request(&context)?;
@@ -1133,6 +1138,7 @@ impl CodexProvider {
let session_capture =
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
account_id: lease.account_id().as_str().to_owned(),
oauth_identity,
credential_revision: matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::ApiKey(_)
@@ -885,6 +885,10 @@ impl CodexBackendClient {
client
.egress_key
.push_str(&format!(":revision:{}", account.revision().get()));
} else {
client
.egress_key
.push_str(&crate::credential::oauth_identity_key(account));
}
client.websocket_origin_key = format!(
"{}:{}",
@@ -476,6 +476,89 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn reauthorization_binds_the_selected_slot_to_the_new_oauth_account() {
use secrecy::ExposeSecret;
let store = Arc::new(MemoryAccountStore::default());
let account_id = ProviderAccountId::new("acct_reauth").unwrap();
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_string(),
name: "local label".into(),
secret: secret("original-access"),
verified_account: account_profile("workspace-a"),
next_refresh_at: None,
enabled: true,
})
.await;
let pending = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
pending.clone(),
Arc::new(Exchanger {
id_token: id_token(serde_json::json!({
"email": "b@example.com",
"https://api.openai.com/auth": {
"chatgpt_user_id": "user-b",
"chatgpt_account_id": "workspace-b",
"chatgpt_plan_type": "plus"
}
})),
}),
store,
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation(account_id.clone(), "reauth-request"),
})
.await
.unwrap();
let state = pending
.pending
.lock()
.unwrap()
.as_ref()
.unwrap()
.state
.clone();
let completed = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: "test-owner".into(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://127.0.0.1:1455/auth/callback?code=browser&state={}",
state.expose_secret()
)),
})
.await
.unwrap();
let CompletedCodexOAuthCredential::Reauthorize(rotation) = completed.credential else {
panic!("expected reauthorization")
};
let identity = rotation.replacement_identity.unwrap();
assert_eq!(identity.upstream_user_id(), "user-b");
assert_eq!(identity.upstream_account_id(), Some("workspace-b"));
assert_eq!(rotation.profile.account_id, account_id);
assert_eq!(rotation.profile.name, "local label");
assert_eq!(rotation.profile.email.as_deref(), Some("b@example.com"));
assert_eq!(rotation.profile.plan_type.as_deref(), Some("plus"));
let credential = rotation.credential.into_parts();
assert!(!credential.preserve_profile);
let runtime = CodexCredentialCodec::decode(&credential.credential).unwrap();
assert!(runtime.principal.is_none());
assert_eq!(
runtime
.authentication
.oauth()
.unwrap()
.access_token
.expose_secret(),
"access-token-from-upstream"
);
}
#[tokio::test]
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
for name in ["", "My account"] {
@@ -2888,6 +2888,102 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
server.verify().await;
}
#[tokio::test]
async fn image_quota_headers_keep_the_codex_weekly_window_and_access_state() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_provider_contract";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("account");
let server = MockServer::start().await;
let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
Arc::new(TestLeaseCoordinator::default()),
0,
);
let core_headers = [
("x-codex-active-limit", "codex"),
("x-codex-primary-used-percent", "95"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "1900604800"),
("x-codex-allowed", "true"),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("initial weekly quota");
let initial = quota.read_account(account.id()).await.unwrap().unwrap();
let response = br#"{"data":[{"b64_json":"AAEC"}]}"#;
// 生图响应仅用 active-limit 标明归属,窗口仍使用通用 x-codex 头
Mock::given(method("POST"))
.and(path("/codex/images/generations"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("x-codex-active-limit", "imagegen_premium")
.insert_header("x-codex-primary-used-percent", "0")
.insert_header("x-codex-primary-window-minutes", "1440")
.insert_header("x-codex-primary-reset-at", "1900000000")
.insert_header("x-codex-secondary-used-percent", "0")
.insert_header("x-codex-secondary-window-minutes", "0")
.insert_header("x-codex-secondary-reset-at", "")
.set_body_raw(response.as_slice(), "application/json"),
)
.expect(1)
.mount(&server)
.await;
let payload = RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"model":"gpt-image-2.6-flare","prompt":"a tree"}"#),
)
.unwrap();
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
payload,
));
let mut stream = provider
.execute(
planned_provider_endpoint_request("openai", operation),
context("req_image_quota", CancellationToken::new()),
)
.await
.expect("image stream");
let mut delivered = None;
while let Some(event) = stream.next().await {
let event = event.expect("image event");
if let Some(body) = event.wire_event().and_then(|wire| wire.raw_json_body()) {
delivered = Some(body.clone());
}
}
assert_eq!(delivered.as_deref(), Some(response.as_slice()));
let snapshot = quota.read_account(account.id()).await.unwrap().unwrap();
let core = snapshot
.windows()
.iter()
.find(|window| window.is_account_wide())
.unwrap();
let image = snapshot
.windows()
.iter()
.find(|window| window.source() == "imagegen_premium")
.unwrap();
assert_eq!(core, &initial.windows()[0]);
assert_eq!(image.window_seconds(), Some(86_400));
assert_eq!(image.used_percent(), Some(0.0));
assert!(!image.is_account_wide());
assert_eq!(store.account(account_id).unwrap().quota(), initial.quota());
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("next Codex quota observation");
let refreshed = quota.read_account(account.id()).await.unwrap().unwrap();
assert_eq!(refreshed.windows(), snapshot.windows());
server.verify().await;
}
#[tokio::test]
async fn image_endpoints_should_report_usage_before_delivering_the_unchanged_body() {
let response = br#"{ "created":1778832973,"data":[{"b64_json":"AAEC"}],"quality":"medium","size":"1024x1536","usage":{"input_tokens":1474,"input_tokens_details":{"image_tokens":1457,"text_tokens":17,"cached_tokens":100},"output_tokens":1372,"output_tokens_details":{"image_tokens":1372,"text_tokens":0},"total_tokens":2846},"future":9007199254740993 }"#;
@@ -289,6 +289,21 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let account = |user: &str| {
use gateway_core::account::{CredentialRevision, ProviderAccount, ProviderAccountId};
ProviderAccount::new(
ProviderAccountId::new("acct_shared_slot").unwrap(),
gateway_core::routing::ProviderKind::new("openai").unwrap(),
String::new(),
Some(user.to_owned()),
"oauth".to_owned(),
CredentialRevision::new(1).unwrap(),
None,
)
.with_profile(None, Some("chatgpt-account".into()), None)
};
let replacement = backend.for_account(&account("user-b")).unwrap();
let backend = backend.for_account(&account("user-a")).unwrap();
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -309,6 +324,16 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
let mut continuation = first_request;
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
assert!(
replacement
.create_response(
&continuation,
request_context("req_reauthorized", Some("chatgpt-account"))
)
.await
.is_err(),
"同一工作区的另一个用户不能续用旧账号的连接"
);
let changed_installation = backend
.create_response(
&continuation,
+1 -1
View File
@@ -959,7 +959,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+4 -2
View File
@@ -1598,8 +1598,10 @@ OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上
失效后需取得新 PAT 再导入
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件
现值。回调地址只承载 `code`/`state`,不以 host/path 形式作为拒绝条件
- OpenAI 重新授权允许登录另一个上游账号。保留本地账号 ID、名称和调度设置,按新授权更新令牌、
上游用户与工作区 ID、邮箱和套餐。换号时清除旧账号的额度快照和耗尽状态,新额度由后台重新查询。
无法识别新用户或新身份已绑定其他记录时,原账号保持不变
- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
+5
View File
@@ -505,6 +505,7 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
OpenAI OAuth 的连接、续接状态、Guardian 路由 Cookie 和头像缓存按本地账号、上游用户与工作区隔离,同号令牌续期不改变归属。
OpenAI Provider 统一处理 Responses 的账号归属与 turn state 隔离,HTTP 与 WS 消费同一出站请求。
全量请求缺少本地归属记录时保留客户端回传的路由 token,明确换号或可信续接状态不属于同一轮时清除;
turn state 不参与选号,也不授权其他账号状态或 response ID 的复用
@@ -1028,6 +1029,10 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护;
空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料
OpenAI 响应头的额度窗口由 `gateway-protocol` 按上游计量项归属解析;仅通过 `x-codex-active-limit`
标明具名额度时,通用 `x-codex-*` 窗口归入该计量项。Provider 按计量项独立保存,生图等具名额度
不覆盖默认 Codex 窗口,也不改变账号级套餐访问结论
OpenAI Provider 统一解释点数可用性,主动查询、耗尽恢复与失败回读复用同一规则。
Store 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。
放行条件与默认值见[账号额度 API](api.md#查询与账号字段)。
+2 -1
View File
@@ -76,7 +76,8 @@ watch(
/>
<FloatingSidebarToggle v-show="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<ZScrollbar ref="pageScrollbarRef">
<!-- 让主滚动区自身绘制不透明底色,浏览器可在合成线程滚动 -->
<ZScrollbar ref="pageScrollbarRef" class="[&>.z-scrollbar-hidden]:bg-z-bg-layout">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
+5 -5
View File
@@ -1,14 +1,14 @@
# v3.43.0
# v3.43.1
## 新增功能
- 管理端主题:可选星津夜航、水墨江汀、粉墙回廊、朱阙金瓦四套场景预设,一次应用配色、控件尺寸和圆角,也可单独切换默认或柔光材质
- macOS:新增 Intel Mac 安装包,可下载 `darwin_amd64` 归档部署
## 优化改进
- 管理端:系统概览和用量页移除与正文重复的悬浮提示,保留截断文本的全文和错误详情
- 管理端:优化主内容区的滚动流畅度,减少系统设置等页面的滚动卡顿
## 问题修复
- 账号调度:网关正常关闭时释放本实例持有的账号并发占位,避免重启后仍需等待旧租约到期
- 账号管理:修复桌面端列表高度计算不准、内容可容纳时仍出现整页滚动条的问题
- OpenAI 账号:重新授权登录另一账号时同步更新身份、邮箱和套餐,隔离旧会话并重新查询额度,保留本地名称和调度设置
- OpenAI 额度:修复生图响应覆盖 Codex 额度窗口的问题,分别保留生图与 Codex 额度
+7
View File
@@ -17,6 +17,13 @@ platforms:
docker: true
dockerPlatform: linux/arm64
- os: darwin
arch: amd64
runner: macos-15-intel
target: x86_64-apple-darwin
binary: codex-proxy-rs
archive: tar.gz
- os: darwin
arch: arm64
runner: macos-15
+5 -5
View File
@@ -1,7 +1,7 @@
version: 3.43.0
version: 3.43.1
published:
'3':
stable: 3.42.1
rc: 3.42.1
beta: 3.42.1
alpha: 3.42.1
stable: 3.43.0
rc: 3.43.0
beta: 3.43.0
alpha: 3.43.0