Compare commits

...
11 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
ZyyandCodex 402bc37fbb chore: bump version to 3.43.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:25:42 +08:00
ZyyandCodex 8b9aacd8af chore(release): distinguish version bumps from channel updates
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:24:05 +08:00
ZyyandCodex d9f9eca7e8 feat(admin): adopt UI v0.9.0 scene presets and shared sidebar
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:15:33 +08:00
ff1ea92155 fix(store): release account concurrency leases on shutdown (#638)
Supervise scheduling lease release tasks and drain them within a bounded shutdown budget. Preserve other instances and the existing TTL fallback.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 01:01:11 +08:00
github-actions[bot] f772bda57b chore(release): publish v3.42.1 2026-11-07 07:14:34 +00:00
70 changed files with 1441 additions and 972 deletions
@@ -20,7 +20,7 @@
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore: bump version to <version>`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
@@ -51,7 +51,7 @@ release/publish "$release_version"
### 修复源码后同版本重发
1. 确认旧工作流已停止、该版本没有公开 Release 或版本镜像,且待清理提交不在其他已发布 tag 的历史中。已有公开产物时保留原提交和 tag,代码修复另发新版本;发布状态不明时先查清
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过失败的 `chore(release): prepare vX.Y.Z`。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过已核实的失败版本提交。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
3. 只清理本次失败的 tag 和草稿。涉及远端改写时,先核对授权,以事先记录的分支头和 tag 对象 SHA 作显式租约,原子更新分支并删除未发行 tag。租约不匹配就停止,不合并旧线
4. 修复验证通过后执行 `release/publish --check <version>`,再按[执行发版](#执行发版)重发。核对上一发行至新 tag 的历史中只有一个本版本的发行提交,且与工作流构建的提交一致
@@ -59,7 +59,7 @@ release/publish "$release_version"
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): publish vX.Y.Z` 提交;重复执行或较旧版本未推进任何通道时无提交
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): update release channels to <version>` 提交;重复执行或较旧版本未推进任何通道时无提交
- 通道表写入失败时,确认该版本已公开、工作流能写入 `main`,再按已有授权执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务重新校验公开附件、读取并合并当前清单,不重新打包或改 tag
- v3.42.0 在线升级:首个包含新更新器的 3.x 正式版公开并通过校验后,从该次通道提交取出 `release/version.yaml`,将顶层 `version` 设为该正式版,作为同名附件上传到该 Release。回读附件,确认 3.x 稳定通道指向该版本且安装包可用,再手动将已有 `update-index` 正文改为 `{"current":"<该正式版>"}`。此后保留该页与附件,无需再编辑
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
@@ -28,7 +28,7 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore(release): prepare v<目标版本>`,旧标题为 `chore(release): v<目标版本>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore: bump version to <version>`;查历史时也匹配 `chore(release): prepare v<version>` 和 `chore(release): v<version>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
+1 -1
View File
@@ -87,7 +87,7 @@ module PublishedChannels
# 按读取的文件 SHA 写入;并发备版改过同一文件时交给恢复任务重新合并
api(endpoint, method: 'PUT', data: {
branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content),
message: "chore(release): publish v#{version}"
message: "chore(release): update release channels to #{version}"
})
end
end
+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![
@@ -275,6 +275,7 @@ async fn connect(
REDIS_NAMESPACE,
)?));
let lease_cleanup = provider_leases.cleanup_worker();
let provider_ports = ProviderStorePorts::new(
account_store,
provider_leases,
@@ -311,6 +312,7 @@ async fn connect(
client_key_usage_writer,
admission_release_writer,
budget_writer,
lease_cleanup,
)?,
None,
),
@@ -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、普通设置和审计共享事务,任何设置失败都回滚凭据更新
@@ -0,0 +1,103 @@
//! 监督本进程账号租约的释放任务,关闭时取消持有请求并有界等待 Redis 清理
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::future::BoxFuture;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
use tokio::sync::{RwLock, RwLockReadGuard, oneshot};
use tokio::task::JoinSet;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone, Default)]
pub(crate) struct SchedulingLeaseCleanup(Arc<State>);
#[derive(Default)]
struct State {
stopping: CancellationToken,
acquisitions: RwLock<()>,
tasks: Mutex<JoinSet<()>>,
}
pub(crate) struct SchedulingLeaseGuard {
_release: oneshot::Sender<()>,
// 活跃请求可能比组合根活得久,不能因端口析构提前中止其续期与租约
_cleanup: SchedulingLeaseCleanup,
}
impl SchedulingLeaseCleanup {
// 关闭必须等已发出的申请完成登记;不能漏掉 Redis 已创建、尚未返回的租约
pub(crate) async fn acquire(&self) -> Option<RwLockReadGuard<'_, ()>> {
let permit = self.0.acquisitions.read().await;
(!self.0.stopping.is_cancelled()).then_some(permit)
}
pub(crate) fn track(
&self,
cancellation: CancellationToken,
release: impl Future<Output = crate::StoreResult<bool>> + Send + 'static,
) -> SchedulingLeaseGuard {
let (guard, dropped) = oneshot::channel::<()>();
let stopping = self.0.stopping.clone();
let mut tasks = self
.0
.tasks
.lock()
.unwrap_or_else(|error| error.into_inner());
while let Some(result) = tasks.try_join_next() {
report_join(result);
}
tasks.spawn(async move {
tokio::select! {
biased;
() = stopping.cancelled() => cancellation.cancel(),
_ = dropped => {}
}
if let Err(error) = release.await {
tracing::warn!(%error, "账号租约释放失败,依赖 TTL 回收");
}
});
SchedulingLeaseGuard {
_release: guard,
_cleanup: self.clone(),
}
}
async fn shutdown(&self) {
self.0.stopping.cancel();
let drain = async {
let _closed = self.0.acquisitions.write().await;
let mut tasks = std::mem::take(
&mut *self
.0
.tasks
.lock()
.unwrap_or_else(|error| error.into_inner()),
);
while let Some(result) = tasks.join_next().await {
report_join(result);
}
};
if tokio::time::timeout(SHUTDOWN_TIMEOUT, drain).await.is_err() {
tracing::warn!("账号租约关闭清理超时,剩余租约依赖 TTL 回收");
}
}
}
impl DaemonTask for SchedulingLeaseCleanup {
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
context.cancellation().cancelled().await;
self.shutdown().await;
Ok(())
})
}
}
fn report_join(result: Result<(), tokio::task::JoinError>) {
if let Err(error) = result {
tracing::warn!(%error, "账号租约释放任务中断,依赖 TTL 回收");
}
}
@@ -23,6 +23,7 @@ use crate::{Revision, StoreError, StoreResult, redis_unavailable, require_nonemp
use super::{MAX_REDIS_EXACT_INTEGER, namespace, resource_fingerprint};
mod cleanup;
mod details;
const SIGNAL_TTL_MILLIS: u64 = 24 * 60 * 60 * 1_000;
@@ -255,6 +256,17 @@ impl CredentialLeaseGuard {
deadline: gateway_core::lifecycle::Deadline,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> StoreResult<impl gateway_core::provider_ports::ProviderLeaseGuard> {
Ok(RenewingSchedulingLease {
_renewal: self.renewal(deadline, cancellation)?,
_guard: self,
})
}
fn renewal(
&self,
deadline: gateway_core::lifecycle::Deadline,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> StoreResult<crate::lease_renewal::LeaseRenewal> {
let repository = self.repository.clone();
let lease_request = self.request.clone();
let grant = self
@@ -265,7 +277,7 @@ impl CredentialLeaseGuard {
entity: "credential lease",
message: "lease has already been released".to_owned(),
})?;
let renewal = crate::lease_renewal::LeaseRenewal::spawn(
Ok(crate::lease_renewal::LeaseRenewal::spawn(
deadline,
Some((
cancellation,
@@ -283,11 +295,7 @@ impl CredentialLeaseGuard {
.map(|grant| grant.is_some())
})
},
);
Ok(RenewingSchedulingLease {
_renewal: renewal,
_guard: self,
})
))
}
#[must_use]
@@ -591,6 +599,7 @@ pub(crate) struct RedisProviderLeaseCoordinator {
repository: RedisCredentialLeaseRepository,
process_id: String,
sequence: AtomicU64,
cleanup: cleanup::SchedulingLeaseCleanup,
}
impl RedisProviderLeaseCoordinator {
@@ -600,9 +609,14 @@ impl RedisProviderLeaseCoordinator {
repository,
process_id: format!("gateway_{}", Uuid::now_v7().simple()),
sequence: AtomicU64::new(0),
cleanup: cleanup::SchedulingLeaseCleanup::default(),
}
}
pub(crate) fn cleanup_worker(&self) -> impl gateway_core::task::DaemonTask + use<> {
self.cleanup.clone()
}
fn owner_id(&self, operation: &str) -> String {
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed);
format!("{}:{operation}:{sequence}", self.process_id)
@@ -662,6 +676,12 @@ impl RedisProviderLeaseCoordinator {
&self,
request: &ProviderSchedulingLeaseRequest,
) -> Result<ProviderLeaseAcquisition, ProviderStoreError> {
let _acquisition = self.cleanup.acquire().await.ok_or_else(|| {
ProviderStoreError::new(
ProviderStoreErrorKind::Unavailable,
"account lease coordinator is shutting down",
)
})?;
let ttl = request.deadline().bounded(REQUEST_LEASE_TTL);
if ttl.is_zero() {
return Err(ProviderStoreError::new(
@@ -685,13 +705,18 @@ impl RedisProviderLeaseCoordinator {
.map_err(|source| crate::provider_unavailable("acquire scheduling lease", source))?;
Ok(match acquisition {
CredentialBoundedLeaseAcquisition::Acquired(guard) => {
ProviderLeaseAcquisition::Acquired(Box::new(
guard
.maintain(request.deadline(), request.cancellation())
.map_err(|source| {
crate::provider_unavailable("maintain scheduling lease", source)
})?,
))
let renewal = guard
.renewal(request.deadline(), request.cancellation())
.map_err(|source| {
crate::provider_unavailable("maintain scheduling lease", source)
})?;
ProviderLeaseAcquisition::Acquired(Box::new(self.cleanup.track(
request.cancellation(),
async move {
drop(renewal);
guard.release().await
},
)))
}
CredentialBoundedLeaseAcquisition::Busy { retry_after } => {
ProviderLeaseAcquisition::Busy { retry_after }
@@ -105,6 +105,7 @@ pub(crate) fn store_worker_contributions(
client_key_usage_writer: postgres::PgClientApiKeyUsageWriter,
admission_release_writer: redis::ClientAdmissionReleaseWriter,
budget_writer: postgres::ClientBudgetWriter,
lease_cleanup: impl DaemonTask + 'static,
) -> StoreResult<Vec<WorkerContribution>> {
let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres")
.map_err(worker_definition_error)?;
@@ -117,10 +118,22 @@ pub(crate) fn store_worker_contributions(
.map_err(worker_definition_error)?;
let budget_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "postgres_client_budget")
.map_err(worker_definition_error)?;
let lease_cleanup_id = WorkerId::try_new(WorkerKind::OpsFlush, "redis_account_leases")
.map_err(worker_definition_error)?;
let ops_flush_restart =
DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(worker_definition_error)?;
Ok(vec![
WorkerContribution::Registration(
WorkerRegistration::try_new(
lease_cleanup_id,
WorkerRunnable::Daemon {
restart: ops_flush_restart,
task: Box::new(lease_cleanup),
},
)
.map_err(worker_definition_error)?,
),
WorkerContribution::Registration(
WorkerRegistration::try_new(
budget_flush_id,
+161 -1
View File
@@ -1,4 +1,4 @@
//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界
//! 验证 Store 认证、凭据编码、只读写入边界与账号租约关闭清理
use gateway_core::{
account::{
@@ -13,6 +13,166 @@ use sqlx::postgres::PgPoolOptions;
use crate::support::config_connection;
#[tokio::test]
async fn shutdown_releases_owned_account_pools_and_preserves_other_instances() {
use gateway_core::lifecycle::{CancellationToken, Deadline};
use gateway_core::provider_ports::{
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseRequest,
ProviderSchedulingLeaseRequest,
};
use gateway_core::task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable};
use std::{num::NonZeroU32, time::Duration};
let (Some(database), Some(redis)) = (
crate::support::test_env("CPR_TEST_DATABASE_URL"),
crate::support::test_env("CPR_TEST_REDIS_URL"),
) else {
return;
};
let mut config: StoreConfig = serde_json::from_value(json!({
"database": config_connection(url::Url::parse(&database).unwrap()),
"redis": config_connection(url::Url::parse(&redis).unwrap()),
}))
.unwrap();
let directory = tempfile::tempdir().unwrap();
config.resolve_and_validate(directory.path()).unwrap();
// 只使用真实 Bundle 的租约端口与关闭任务,不需要创建业务账号或启动其他 Worker
let mut first = gateway_store::initialize_read_only(config.clone())
.await
.unwrap();
let mut second = gateway_store::initialize_read_only(config).await.unwrap();
let first_port = first.provider_ports().leases();
let second_port = second.provider_ports().leases();
let account =
ProviderAccountId::new(format!("acct_shutdown_{}", uuid::Uuid::new_v4().simple())).unwrap();
let mut first_requests = Vec::new();
let mut first_guards = Vec::new();
let mut second_requests = Vec::new();
let mut second_guards = Vec::new();
for pool in [
ProviderConcurrencyPool::Shared,
ProviderConcurrencyPool::Reserved,
] {
let request = ProviderSchedulingLeaseRequest::new(
ProviderKind::new("openai").unwrap(),
account.clone(),
CredentialRevision::new(1).unwrap(),
NonZeroU32::new(2).unwrap(),
Deadline::default(),
)
.with_concurrency_pool(pool);
let ProviderLeaseAcquisition::Acquired(first_guard) = first_port
.try_acquire(ProviderLeaseRequest::Scheduling(request.clone()))
.await
.unwrap()
else {
panic!("first instance must acquire a slot")
};
let other = request.clone().with_cancellation(CancellationToken::new());
let ProviderLeaseAcquisition::Acquired(second_guard) = second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap()
else {
panic!("second instance must acquire a slot")
};
assert!(matches!(
second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap(),
ProviderLeaseAcquisition::Busy { .. }
));
first_requests.push(request);
second_requests.push(other);
first_guards.push(first_guard);
second_guards.push(second_guard);
}
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
2
);
for (index, store) in [&mut first, &mut second].into_iter().enumerate() {
let cleanup = store
.take_worker_contributions()
.into_iter()
.find_map(|contribution| {
let WorkerContribution::Registration(registration) = contribution else {
return None;
};
if registration.id.owner() != "redis_account_leases" {
return None;
}
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("cleanup is a daemon")
};
Some(task)
})
.expect("account lease shutdown worker must be registered");
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
if index == 0 {
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
1
);
for (first_request, other) in first_requests.iter().zip(&second_requests) {
assert!(first_request.cancellation().is_cancelled());
assert!(!other.cancellation().is_cancelled());
assert!(
first_port
.try_acquire(ProviderLeaseRequest::Scheduling(first_request.clone()))
.await
.is_err()
);
let replacement = second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap();
assert!(matches!(
&replacement,
ProviderLeaseAcquisition::Acquired(_)
));
assert!(matches!(
second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap(),
ProviderLeaseAcquisition::Busy { .. }
));
drop(replacement);
}
}
}
assert!(
second_requests
.iter()
.all(|request| request.cancellation().is_cancelled())
);
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
0
);
drop((first_guards, second_guards));
}
#[tokio::test]
async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
let (Some(database), Some(redis)) = (
@@ -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;
}
@@ -0,0 +1,132 @@
//! 验证账号租约关闭与申请交错、异步释放完成和清理超时
#[path = "../../../src/redis/credential_leases/cleanup.rs"]
mod implementation;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
use implementation::SchedulingLeaseCleanup;
use std::time::Duration;
use tokio::sync::oneshot;
#[tokio::test]
async fn live_guard_keeps_its_lease_task_alive_after_the_port_is_dropped() {
let cleanup = SchedulingLeaseCleanup::default();
let (released, mut completed) = oneshot::channel();
let guard = cleanup.track(CancellationToken::new(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(cleanup);
tokio::task::yield_now().await;
assert!(matches!(
completed.try_recv(),
Err(oneshot::error::TryRecvError::Empty)
));
drop(guard);
}
#[tokio::test]
async fn shutdown_waits_for_pending_acquisitions_and_releases_without_waiting_for_guards() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let stopping = CancellationToken::new();
let request = CancellationToken::new();
let (released, completed) = oneshot::channel();
let worker = {
let cleanup = cleanup.clone();
let stopping = stopping.clone();
tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await })
};
stopping.cancel();
tokio::task::yield_now().await;
assert!(!worker.is_finished());
// Redis 申请在停机开始后才返回,登记仍必须被本次清理包含
let guard = cleanup.track(request.clone(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(acquisition);
tokio::time::timeout(Duration::from_secs(1), worker)
.await
.unwrap()
.unwrap()
.unwrap();
completed.await.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
#[tokio::test]
async fn shutdown_joins_a_release_already_started_by_guard_drop() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let request = CancellationToken::new();
let (started, entered) = oneshot::channel();
let (finish, finished) = oneshot::channel();
let guard = cleanup.track(request.clone(), async move {
started.send(()).unwrap();
finished.await.unwrap();
Ok(true)
});
drop(acquisition);
drop(guard);
entered.await.unwrap();
assert!(!request.is_cancelled());
let stopping = CancellationToken::new();
stopping.cancel();
let worker = tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await });
tokio::task::yield_now().await;
assert!(!worker.is_finished());
finish.send(()).unwrap();
worker.await.unwrap().unwrap();
}
#[tokio::test(start_paused = true)]
async fn unavailable_release_does_not_hold_shutdown_past_its_budget() {
let cleanup = SchedulingLeaseCleanup::default();
let request = CancellationToken::new();
let guard = cleanup.track(request.clone(), std::future::pending());
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
#[tokio::test(start_paused = true)]
async fn acquisition_returning_after_shutdown_timeout_is_still_released() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
let request = CancellationToken::new();
let (released, completed) = oneshot::channel();
let guard = cleanup.track(request.clone(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(acquisition);
tokio::time::timeout(Duration::from_secs(1), completed)
.await
.unwrap()
.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
@@ -1,5 +1,6 @@
//! 验证账号调度租约的容量、续租、失效与释放
mod cleanup;
mod details;
use std::collections::BTreeMap;
@@ -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,
+4 -1
View File
@@ -669,6 +669,9 @@ metrics_exporter = "none"
WebSocket 会话完成,进程退出时强制断开剩余连接。已入队的观测写入与后台清理保留
`host.worker_shutdown_timeout_seconds`(默认 30 秒)的收尾预算;客户端在新进程就绪后重新连接
正常关闭会在后台收尾预算内释放本实例的账号并发占位,租约清理最多等待 2 秒。
强制终止、清理超时或 Redis 清理失败时,占位由最后一次续期后的 10 分钟 TTL 回收,不影响其他实例的租约
管理端重启受理后,到进程退出期间,更新、回滚和再次重启均被拒绝;可继续查询更新状态
Compose 的 `stop_grace_period` 为 75 秒,覆盖默认 30 秒后台收尾及进程清理余量。
@@ -956,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
+8 -1
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 的复用
@@ -839,7 +840,9 @@ Core 的 `concurrency` 拥有中立的有界等待位置、优先级与同级 FI
运行并发仍由 Redis 原子准入与账号租约裁决,等待队列只保存当前进程中的等待位置,不复制运行计数,
也不持久化正文或在重启后重放请求。每个等待 owner 最多容纳 1,024 个等待者作为资源兜底
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放,异常退出后随 TTL 回收;管理端只对普通池做有界分页读取
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放;管理端只对普通池做有界分页读取
Store 监督本进程普通池与审批池的账号租约释放任务,停机时拒绝新申请、取消持有请求并停止续期,在 2 秒预算内完成已发出申请的登记及租约释放,不等待模型生成结束,也不清理其他实例的租约。异常退出或清理失败时仍由 TTL 回收
新请求不能越过同级或更高优先级的等待者,取消或完成等待通过 Drop 回收位置并立即唤醒后继
队首每 100ms 重查容量,需要复核会话绑定或跨账号容量的非队首每 500ms 重查,其余非队首等待晋升通知
@@ -1026,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#查询与账号字段)。
+35 -14
View File
@@ -12,7 +12,7 @@
</div>
在管理端「主题」中调整颜色、字号、圆角和组件样式,保存后应用到当前浏览器。
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择预置主题即可
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择品牌色预置即可
下文说明实现和扩展方式。颜色使用 Ant Design 十阶色板与项目的明暗角色规则,
中性表面从背景和文字 Seed 派生;Vue 组件通过 CSS Variables 与 Tailwind CSS 4 使用这些值。
@@ -138,6 +138,7 @@ Seed 是用户配置与持久化的最小事实:
```ts
type ThemeMode = 'system' | 'light' | 'dark'
type ThemePresetId = 'default' | 'soft'
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
interface ThemeCustomization {
@@ -157,7 +158,11 @@ interface ThemeCustomization {
表格行高与卡片圆角通过 `customization.component` 的 `tableRowHeight`、`cardBorderRadius` 配置,属于组件尺寸覆盖
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
完整主题预设组合品牌配色、推荐材质、全局 Seed、组件尺寸和可编辑 Token。选择时替换草稿配置,保留当前明暗模式;保存后应用到管理端
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。切换材质保留草稿的配色和组件配置,显式尺寸优先于材质默认值
持久化键为 `codex-proxy-rs-theme`,`themePreset` 缺省为 `default`。Store 不保存完整色板或 CSS Variables,损坏的模式、预设、颜色与自定义值会在
初始化时规范化并回退到默认配置
### Map Token
@@ -201,7 +206,7 @@ Preset 的实心色使用 P6,普通 Container、Strong Container 与边界按
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
中继蓝使用稳定的浅色 / 深色中性基线;深海青、古风色与石墨预置额外提供各自的 `colorBgBase`、`colorTextBase`
星津夜航使用稳定的浅色 / 深色中性基线;水墨江汀、粉墙回廊与朱阙金瓦预置额外提供各自的 `colorBgBase`、`colorTextBase`
画像,自定义主色则只向通用中性基线注入少量色温。默认浅色采用中性锚点,默认深色使用 HSL 色调派生;带色温
主题按外观距离平滑过渡到背景和文字 Seed 的 RGB 混色结果。稳定锚点用于 Surface 与 Shadow,Component 从角色派生,
所有预置、自定义 Seed 和用户覆盖仍进入同一条算法,不在页面或组件中追加 HEX 特判。
@@ -242,14 +247,16 @@ Token 直接覆盖时不会自动重算同组件的其他状态;需要保持
| Button | `--z-button-primary-color / bg / hover-bg / active-bg`、`--z-button-secondary-bg / hover-bg / active-bg` |
| IconButton | `--z-icon-button-secondary-bg / hover-bg / active-bg` |
| Input | `--z-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
| Menu | `--z-menu-item-selected-bg` |
| Menu | `--z-menu-item-selected-bg / bg-image / shadow` |
| Popover | `--z-popover-header-bg`、`--z-popover-shadow` |
| Table | `--z-table-row-bg / stripe-bg / hover-bg / selected-bg / selected-hover-bg / height` |
| Card | `--z-card-bg / border-radius / shadow` |
| Layout | `--z-layout-sider-bg / shadow` |
| Card | `--z-card-bg / bg-image / border-radius / shadow` |
| Layout | `--z-layout-sider-bg / bg-image / border-radius / shadow` |
| Scrollbar | `--z-scrollbar-thumb-bg / hover-bg` |
| BrandMark | `--z-brand-mark-bg` |
管理端侧栏由 UI 库的 `ZSidebar` 提供容器、折叠展示、两级导航与选中状态。应用提供菜单数据、品牌和底部操作插槽,在 `select` 中执行路由或插件导航;移动端遮罩与模态焦点由宿主布局持有
组件状态由 UI 库的 Component 派生层统一生成,页面不维护混色系数:
| 角色 | 状态与可读性约束 |
@@ -279,14 +286,23 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
## 预置主题
| ID | 名称 | 主色 | 交互方向 |
| --- | --- | --- | --- |
| `relay-blue` | 中继蓝 | `#5983F4` | 清晰可信的冷蓝强调 |
| `deep-teal` | 深海青 | `#0E7C72` | 冷静低饱和的青色强调 |
| `signal-violet` | 古风色 | `#A0583D` | 温厚克制的赭色强调 |
| `graphite` | 石墨 | `#525B66` | 近单色的灰色强调 |
### 表面材质
预置主题改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。
| ID | 名称 | 推荐品牌色 | 表面 |
| --- | --- | --- | --- |
| `default` | 默认 | 星津夜航 | 简洁表面与轻量阴影 |
| `soft` | 柔光 | 星津夜航 | 圆润边缘、表面高光与分层阴影,配色跟随品牌 |
### 主题预设
| 配色 ID | 名称 | 主色 | 推荐材质与组件 |
| --- | --- | --- | --- |
| `relay-blue` | 星津夜航 | `#5983F4` | 默认材质,标准控件与表格 |
| `deep-teal` | 水墨江汀 | `#50605E` | 默认材质,6px 卡片圆角与 60px 表格行高 |
| `signal-violet` | 粉墙回廊 | `#D395A8` | 默认材质,14px 正文与 68px 表格行高 |
| `graphite` | 朱阙金瓦 | `#A34537` | 柔光,12px 卡片圆角与 54px 表格行高 |
品牌配色改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。完整预设提供的 Seed 优先于品牌基线,用户覆盖优先于预设
所有预置与自定义主题走同一条 Surface、Map、Alias 与 Component 派生链路,不存在页面级特判
## 运行时架构
@@ -349,7 +365,8 @@ UI 构建插件 `CodexProxyUI({ themePrefix: 'z' })` 同时配置变量生成与
| 区域 | 能力 |
| --- | --- |
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 预设 | 模式、完整主题预设、表面材质 |
| 全局 / 颜色 | 自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
| 全局 / 风格 | 通用圆角、阴影强度 |
| 组件 | Action、Form、Surface、Data Display、Navigation、Layout |
@@ -431,6 +448,10 @@ utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文
### 增加预置主题
在 UI 库的 `themeProfiles` 中组合配色 ID 对应的材质与 `ThemeCustomization`。设置页将其复制为可编辑草稿,保存实际配置,无需持久化额外的选中标记。材质仍由组件 Token 派生层实现
单独增加品牌配色:
1. 在 `ThemeColorPresetId` 增加稳定 ID
2. 在 `themeColorPresets` 声明名称、描述与主色 Seed
3. 不新增页面特判,确认预置可通过同一 `createTheme()` 派生
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.67.0",
+6 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.8.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
specifier: github:zyycn/codex-proxy-ui#v0.9.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -443,9 +443,9 @@ packages:
resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4':
resolution: {gitHosted: true, integrity: sha512-fQX6Xns/LbCYb9mFPzWXm34Y904pTywi66eBOGIBAW12eRKdVcMJQRfNHTbYDKsYIXpZ/fii6HwQqv+/T3XXKA==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4}
version: 0.8.1
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86':
resolution: {gitHosted: true, integrity: sha512-RfPWnA41PRi4Q9gBPXK4jyik+9XZDLKaRMF12/Y4au6wQd5+7C+fIBVc6iROQFSxTW43EmEl8Gsx3tbNXE0w/Q==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86}
version: 0.9.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -3487,7 +3487,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
+10 -4
View File
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
"themePresets": {
"material": "Surface material",
"title": "Theme preset",
"default": {
"label": "Default"
},
"soft": {
"label": "Soft glow"
}
},
"settings": {
"global": "Global",
"components": "Components",
@@ -177,7 +187,6 @@
"scrollBarSlider": "Scroll bar slider",
"scrollBarHover": "Scroll bar Hover",
"componentTokenComponentFamily": "Component token family",
"description": "Adjust component tokens for {label}, unchanged values continue to use the global seed and alias algorithms",
"noMatchingComponentToken": "No matching component tokens",
"kindLabel": "Component tokens"
},
@@ -204,8 +213,6 @@
"themeMode": "Theme mode",
"globalTokenClassification": "Global token categories",
"brandColorLabel": "Preset theme · Brand color · colorPrimary",
"brandColor": "Brand color",
"presetThemes": "Preset themes",
"brandMainColor": "Brand main color",
"paletteDescription": "Color scales and overall appearance",
"semanticColorsTitle": "Semantic and neutral colors",
@@ -215,7 +222,6 @@
"basicSpacing": "Basic spacing",
"sizeStep": "Size step",
"styleToken": "Style tokens",
"surfaceDescription": "Borderless surfaces, rounded corners, and layered shadows",
"universalFillet": "Universal fillet",
"shadowIntensity": "Shadow intensity"
}
@@ -16,7 +16,6 @@
"switchLanguage": "Switch language ({value})",
"languageLoadFailed": "Unable to load language, reload to try again",
"mainLabel": "Main navigation",
"pluginsLabel": "Plugin navigation",
"pluginManagement": "Manage",
"online": "Online",
"sidebar": {
+1 -1
View File
@@ -629,7 +629,7 @@
"access": "Security",
"backups": "Backups"
},
"description": "Manage scheduling, upstreams, pricing, security, history retention, and backups",
"description": "Manage scheduling, upstreams, pricing, security, retention and backups",
"sectionsLabel": "Settings sections",
"undoAllBasicSettingsChanges": "Undo all basic settings changes",
"unsavedChanges": {
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "切换浅色模式",
"switchToDark": "切换暗黑模式",
"themePresets": {
"material": "表面材质",
"title": "主题预设",
"default": {
"label": "默认"
},
"soft": {
"label": "柔光"
}
},
"settings": {
"global": "全局",
"components": "组件",
@@ -177,7 +187,6 @@
"scrollBarSlider": "滚动条滑块",
"scrollBarHover": "滚动条 Hover",
"componentTokenComponentFamily": "Component Token 组件族",
"description": "调整 {label} 的 Component Token,未修改的值继续由全局 Seed / Alias 算法生成",
"noMatchingComponentToken": "没有匹配的 Component Token",
"kindLabel": "组件 Token"
},
@@ -204,8 +213,6 @@
"themeMode": "主题模式",
"globalTokenClassification": "全局 Token 分类",
"brandColorLabel": "预置主题 品牌色 colorPrimary",
"brandColor": "品牌色",
"presetThemes": "预置主题",
"brandMainColor": "品牌主色",
"paletteDescription": "完整色阶与界面基调",
"semanticColorsTitle": "功能色与中性色",
@@ -215,7 +222,6 @@
"basicSpacing": "基础间距",
"sizeStep": "尺寸步长",
"styleToken": "风格 Token",
"surfaceDescription": "固定无边设计,圆角塑形,阴影分层",
"universalFillet": "通用圆角",
"shadowIntensity": "阴影强度"
}
@@ -16,7 +16,6 @@
"switchLanguage": "切换语言({value})",
"languageLoadFailed": "语言加载失败,请刷新重试",
"mainLabel": "主导航",
"pluginsLabel": "插件导航",
"pluginManagement": "插件管理",
"online": "在线",
"sidebar": {
@@ -629,7 +629,7 @@
"access": "安全与访问",
"backups": "数据备份"
},
"description": "配置网关调度、上游与定价,管理访问安全、历史保留与备份",
"description": "管理调度、上游、定价、安全、数据留存与备份",
"sectionsLabel": "设置分区",
"undoAllBasicSettingsChanges": "撤销全部基础设置更改",
"unsavedChanges": {
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "切換淺色模式",
"switchToDark": "切換暗黑模式",
"themePresets": {
"material": "表面材質",
"title": "主題預設",
"default": {
"label": "預設"
},
"soft": {
"label": "柔光"
}
},
"settings": {
"global": "全局",
"components": "組件",
@@ -177,7 +187,6 @@
"scrollBarSlider": "滾動條滑塊",
"scrollBarHover": "滾動條 Hover",
"componentTokenComponentFamily": "Component Token 組件族",
"description": "調整 {label} 的 Component Token,未修改的值繼續由全局 Seed / Alias 算法生成",
"noMatchingComponentToken": "沒有匹配的 Component Token",
"kindLabel": "組件 Token"
},
@@ -204,8 +213,6 @@
"themeMode": "主題模式",
"globalTokenClassification": "全局 Token 分類",
"brandColorLabel": "預置主題 品牌色 colorPrimary",
"brandColor": "品牌色",
"presetThemes": "預置主題",
"brandMainColor": "品牌主色",
"paletteDescription": "完整色階與界面基調",
"semanticColorsTitle": "功能色與中性色",
@@ -215,7 +222,6 @@
"basicSpacing": "基礎間距",
"sizeStep": "尺寸步長",
"styleToken": "風格 Token",
"surfaceDescription": "固定無邊設計,圓角塑形,陰影分層",
"universalFillet": "通用圓角",
"shadowIntensity": "陰影強度"
}
@@ -16,7 +16,6 @@
"switchLanguage": "切換語言({value})",
"languageLoadFailed": "語言加載失敗,請刷新重試",
"mainLabel": "主導航",
"pluginsLabel": "插件導航",
"pluginManagement": "插件管理",
"online": "在線",
"sidebar": {
@@ -629,7 +629,7 @@
"access": "安全與訪問",
"backups": "數據備份"
},
"description": "配置網關調度、上游與定價,管理訪問安全、歷史保留與備份",
"description": "管理調度、上游、定價、安全、資料留存與備份",
"sectionsLabel": "設置分區",
"undoAllBasicSettingsChanges": "撤銷全部基礎設置更改",
"unsavedChanges": {
+141 -517
View File
@@ -1,14 +1,13 @@
<script setup lang="ts">
import type { SidebarItem } from '@codex-proxy/ui'
import type { RouteLocationRaw } from 'vue-router'
import type { PluginManagementView } from '@/api'
import { ZIconButton, ZMotionIcon, ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { ZIconButton, ZMotionIcon, ZSidebar } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Blocks,
BringToFront,
ChartNoAxesColumn,
ChevronDown,
FolderTree,
KeyRound,
LayoutDashboard,
@@ -23,13 +22,10 @@ import {
Sun,
Users,
} from '@lucide/vue'
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { gsap } from 'gsap'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
import { useAuthStore } from '@/stores/modules/auth'
@@ -38,17 +34,13 @@ import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useThemeStore } from '@/stores/modules/theme'
import { pluginPageLocation, shortPluginInstanceId } from '@/utils/plugin'
const props = withDefaults(
defineProps<{
collapsed?: boolean
mobile?: boolean
}>(),
{
collapsed: false,
mobile: false,
},
)
const props = withDefaults(defineProps<{
collapse?: boolean
mobile?: boolean
}>(), {
collapse: false,
mobile: false,
})
const emit = defineEmits<{
close: []
navigate: []
@@ -57,7 +49,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n({ useScope: 'global' })
const sidebar = useTemplateRef<InstanceType<typeof ZSidebar>>('sidebar')
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -68,137 +60,87 @@ const { pageViews: pluginPageViews } = storeToRefs(pluginViewsStore)
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
const pluginMenuId = useId()
const pluginGroupExpanded = shallowRef(route.path.startsWith('/plugins'))
const isCollapsed = computed(() => !props.mobile && props.collapse)
const navItems = computed(() => ([
{
label: t('navigation.overview'),
icon: LayoutDashboard,
path: '/',
},
{
label: t('navigation.accounts'),
icon: Users,
path: '/accounts',
},
{
label: t('navigation.proxies'),
icon: Network,
path: '/proxies',
},
{
label: t('navigation.groups'),
icon: FolderTree,
path: '/groups',
},
{
label: t('navigation.apiKeys'),
icon: KeyRound,
path: '/keys',
},
{
label: t('navigation.usage'),
icon: ChartNoAxesColumn,
path: '/usage',
},
{
label: t('navigation.plugins'),
icon: Puzzle,
path: '/plugins',
},
{
label: t('navigation.appearance'),
icon: Palette,
path: '/theme',
},
{
label: t('navigation.settings'),
icon: Settings,
path: '/settings',
},
]))
const pluginNavIndex = navItems.value.findIndex(item => item.path === '/plugins')
const pluginMenuVisible = computed(() => pluginGroupExpanded.value && !isCollapsed.value)
const pluginViewNameCounts = computed(() => pluginPageViews.value.reduce((counts, view) => {
const title = view.pages[0]?.title ?? view.name
counts.set(title, (counts.get(title) ?? 0) + 1)
return counts
}, new Map<string, number>()))
const pluginChildrenHeight = computed(() => pluginMenuVisible.value
? (pluginPageViews.value.length + 1) * 44
: 0)
function isActive(path: string) {
if (path === '/')
return route.path === '/'
return route.path.startsWith(path)
}
const activeNavIndex = computed(() => {
const index = navItems.value.findIndex(item => isActive(item.path))
return Math.max(0, index)
})
const activePluginIndex = computed(() => route.name === 'plugins'
? 0
: pluginPageViews.value.findIndex(isPluginViewActive) + 1)
const activeNavIndicatorStyle = computed(() => {
const inPluginMenu = isActive('/plugins') && pluginMenuVisible.value
const offset = inPluginMenu
? 50 + activePluginIndex.value * 44
: activeNavIndex.value > pluginNavIndex
? pluginChildrenHeight.value
: 0
// 主菜单与子菜单共享选中框,位置和尺寸在同一次过渡中变化
return {
transform: `translate3d(${inPluginMenu ? 24 : 0}px, ${activeNavIndex.value * 58 + offset}px, 0)`,
width: inPluginMenu ? 'calc(100% - 24px)' : '100%',
height: inPluginMenu ? '40px' : '46px',
}
})
const navFeedbackMuted = shallowRef(false)
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
() => {
navFeedbackMuted.value = false
const navItems = computed<SidebarItem[]>(() => [
{
index: '/',
label: t('navigation.overview'),
icon: LayoutDashboard,
},
300,
{ immediate: false },
)
function muteNavFeedbackDuringMove() {
navFeedbackMuted.value = true
stopNavFeedbackRestore()
restoreNavFeedback()
}
function navigate(path: string) {
muteNavFeedbackDuringMove()
void router.push(path)
emit('navigate')
}
function togglePluginGroup() {
if (isCollapsed.value) {
navigate('/plugins')
return
{
index: '/accounts',
label: t('navigation.accounts'),
icon: Users,
},
{
index: '/proxies',
label: t('navigation.proxies'),
icon: Network,
},
{
index: '/groups',
label: t('navigation.groups'),
icon: FolderTree,
},
{
index: '/keys',
label: t('navigation.apiKeys'),
icon: KeyRound,
},
{
index: '/usage',
label: t('navigation.usage'),
icon: ChartNoAxesColumn,
},
{
index: 'plugins',
label: t('navigation.plugins'),
icon: Puzzle,
children: [
{
index: '/plugins',
label: t('navigation.pluginManagement'),
icon: BringToFront,
},
...pluginPageViews.value.map(view => ({
index: `plugin:${view.target.instanceId}`,
label: pluginViewLabel(view),
icon: Blocks,
})),
],
},
{
index: '/theme',
label: t('navigation.appearance'),
icon: Palette,
},
{
index: '/settings',
label: t('navigation.settings'),
icon: Settings,
},
])
const activeIndex = computed(() => {
if (route.path.startsWith('/plugins')) {
if (route.name === 'plugins')
return '/plugins'
const view = pluginPageViews.value.find(isPluginViewActive)
return view
? `plugin:${view.target.instanceId}`
: 'plugins'
}
pluginGroupExpanded.value = !pluginGroupExpanded.value
}
function navigatePluginView(view: PluginManagementView) {
const page = view.pages[0]
if (page)
navigate(router.resolve(pluginPageLocation(view, page)).fullPath)
}
return navItems.value.find(item => item.index !== '/' && route.path.startsWith(item.index))?.index ?? '/'
})
function isPluginViewActive(view: PluginManagementView) {
const value = route.params.instanceId
const routeInstanceId = Array.isArray(value)
? value[0]
: value
return route.name === 'plugin-page' && routeInstanceId === view.target.instanceId
return route.name === 'plugin-page' && route.params.instanceId === view.target.instanceId
}
function pluginViewLabel(view: PluginManagementView) {
@@ -211,6 +153,24 @@ function pluginViewLabel(view: PluginManagementView) {
: view.name}`
}
async function handleSelect(index: string) {
// 路由守卫可能取消跳转,菜单保持当前页,直到实际路由改变
sidebar.value?.updateActiveIndex(activeIndex.value)
let target: RouteLocationRaw = index === 'plugins'
? '/plugins'
: index
if (index.startsWith('plugin:')) {
const view = pluginPageViews.value.find(item => `plugin:${item.target.instanceId}` === index)
const page = view?.pages[0]
if (!view || !page)
return
target = pluginPageLocation(view, page)
}
const failure = await router.push(target)
if (!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
emit('navigate')
}
function openSystemUpdate() {
emit('openSystemUpdate')
}
@@ -222,401 +182,65 @@ async function handleLogout() {
emit('navigate')
}
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const collapsedSidebarWidth = 88
const expandedSidebarWidth = 251
const sidebarWidth = computed(() => (isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth))
const brandLabelVisible = shallowRef(!isCollapsed.value)
const themeToggleLabel = computed(() => (effectiveTheme.value === 'dark'
const themeToggleLabel = computed(() => effectiveTheme.value === 'dark'
? t('appearance.switchToLight')
: t('appearance.switchToDark')))
: t('appearance.switchToDark'))
const versionText = computed(() => version.value?.version.trim() ?? '')
const hasVersionLabel = computed(() => versionText.value.length > 0)
const versionLabel = computed(() => `v${versionText.value}`)
const updateButtonLabel = computed(() => (hasUpdate.value
const updateButtonLabel = computed(() => hasUpdate.value
? t('navigation.update.available')
: t('navigation.update.open')))
function prefersReducedMotion() {
return preferredMotion.value === 'reduce'
}
function animateSidebarLabels(collapsed: boolean) {
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (!labels?.length) {
return
}
if (prefersReducedMotion()) {
gsap.set(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
})
return
}
gsap.to(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
duration: collapsed ? 0.16 : 0.2,
ease: collapsed ? 'power2.in' : 'power3.out',
stagger: collapsed ? 0 : 0.018,
overwrite: true,
})
}
function hideBrandLabel() {
const label = brandLabelEl.value
if (label) {
gsap.killTweensOf(label)
gsap.set(label, {
opacity: 0,
x: -6,
})
}
brandLabelVisible.value = false
}
function animateBrandLabelEnter() {
const label = brandLabelEl.value
if (!label) {
return
}
if (prefersReducedMotion()) {
gsap.set(label, {
opacity: 1,
x: 0,
})
return
}
gsap.fromTo(
label,
{
opacity: 0,
x: -6,
},
{
opacity: 1,
x: 0,
duration: 0.2,
ease: 'power3.out',
overwrite: true,
},
)
}
function animateSidebarWidth(collapsed: boolean) {
if (!sidebarEl.value) {
return
}
const targetWidth = collapsed ? collapsedSidebarWidth : expandedSidebarWidth
const currentWidth = sidebarEl.value.getBoundingClientRect().width
if (prefersReducedMotion()) {
gsap.set(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
})
return
}
gsap.set(sidebarEl.value, {
width: currentWidth,
flexBasis: currentWidth,
})
gsap.to(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
duration: 0.34,
ease: 'power3.out',
overwrite: true,
})
}
function animateNavSignal() {
const signal = navSignalEl.value
if (!signal)
return
gsap.killTweensOf(signal)
if (prefersReducedMotion()) {
gsap.set(signal, { opacity: 0, xPercent: -70 })
return
}
gsap.fromTo(
signal,
{ opacity: 0.48, xPercent: -70 },
{
opacity: 0,
xPercent: 120,
duration: 0.52,
ease: 'power2.out',
overwrite: true,
},
)
}
: t('navigation.update.open'))
onMounted(() => {
void pluginViewsStore.ensureLoaded().catch(() => undefined)
gsap.set(sidebarEl.value, {
width: sidebarWidth.value,
flexBasis: sidebarWidth.value,
})
if (isCollapsed.value) {
hideBrandLabel()
}
else {
gsap.set(brandLabelEl.value, {
opacity: 1,
x: 0,
})
}
animateSidebarLabels(isCollapsed.value)
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
})
watch(
() => isCollapsed.value,
async (collapsed) => {
if (collapsed) {
hideBrandLabel()
}
else {
brandLabelVisible.value = true
}
animateSidebarLabels(Boolean(collapsed))
animateSidebarWidth(Boolean(collapsed))
await nextTick()
if (!collapsed) {
animateBrandLabelEnter()
}
},
)
watch(
() => route.path,
async (path, previousPath) => {
if (path === previousPath)
return
if (path.startsWith('/plugins'))
pluginGroupExpanded.value = true
muteNavFeedbackDuringMove()
await nextTick()
animateNavSignal()
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
stopNavFeedbackRestore()
const targets = [
sidebarEl.value,
brandLabelEl.value,
navSignalEl.value,
].filter((target): target is HTMLElement =>
Boolean(target),
)
gsap.killTweensOf(targets)
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (labels?.length) {
gsap.killTweensOf(labels)
}
})
</script>
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--z-layout-sider-bg) shadow-z-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
]"
<ZSidebar
ref="sidebar"
class="z-20"
:class="mobile ? 'flex' : 'hidden min-[961px]:flex'"
:items="navItems"
:default-active="activeIndex"
:collapse="isCollapsed"
:navigation-label="t('navigation.mainLabel')"
@select="handleSelect"
>
<div
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
>
<template #brand-icon>
<ZMotionIcon
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-z"
>
<AppBrandMark class="block size-11 select-none" />
</ZMotionIcon>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<strong class="text-base leading-[1.1] font-heavy text-z-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-z-text-secondary"> Rust build </span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-z-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
:class="[
hasUpdate
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
]"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
</button>
</span>
</template>
<template #brand>
<strong class="text-base leading-[1.1] font-heavy text-z-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-z-text-secondary"> Rust build </span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-z-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
:class="[
hasUpdate
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
]"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
</button>
</span>
</div>
<ZScrollbar class="my-6 w-full flex-1">
<div class="px-4">
<!-- 焦点环绘制在按钮内侧,避免被滚动区和折叠菜单裁切 -->
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" :aria-label="t('navigation.mainLabel')">
<span
class="pointer-events-none absolute left-0 top-0 overflow-hidden rounded-z bg-z-menu-item-selected-bg transition-[transform,width,height] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span
ref="navSignal"
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--z-color-info)_9%,transparent),transparent)]"
/>
</span>
<template v-for="item in navItems" :key="item.label">
<div v-if="item.path === '/plugins'" class="relative z-10 grid min-w-0">
<ZPopover trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="w-full">
<template #reference="{ close }">
<button
type="button"
class="inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 bg-transparent text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'font-bold text-z-text transition-none'
: 'font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'font-semibold text-z-text-secondary transition-none'
: 'font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-expanded="isCollapsed ? undefined : pluginGroupExpanded"
:aria-controls="isCollapsed ? undefined : pluginMenuId"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); togglePluginGroup()"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'hidden' : 'w-auto'"
>
{{ item.label }}
</span>
<span
v-if="!isCollapsed"
class="sidebar-label inline-flex size-4 shrink-0"
aria-hidden="true"
>
<ChevronDown
class="size-4 transition-transform duration-200 motion-reduce:transition-none"
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
/>
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
<div
:id="pluginMenuId"
class="grid transition-[grid-template-rows] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="pluginMenuVisible ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:inert="!pluginMenuVisible"
:aria-label="t('navigation.pluginsLabel')"
>
<div class="min-h-0 overflow-hidden">
<div class="relative ml-3 grid gap-1 pt-1 pl-3">
<button
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="route.name === 'plugins'
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="route.name === 'plugins' ? 'page' : undefined"
@click="navigate('/plugins')"
>
<BringToFront class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ t('navigation.pluginManagement') }}</span>
</button>
<button
v-for="view in pluginPageViews"
:key="view.target.instanceId"
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="isPluginViewActive(view)
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="isPluginViewActive(view) ? 'page' : undefined"
@click="navigatePluginView(view)"
>
<Blocks class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ pluginViewLabel(view) }}</span>
</button>
</div>
</div>
</div>
</div>
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
<template #reference="{ close }">
<button
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-z-text transition-none'
: 'bg-transparent font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-z-text-secondary transition-none'
: 'bg-transparent font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); navigate(item.path)"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
>
{{ item.label }}
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
</template>
</nav>
</div>
</ZScrollbar>
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
</template>
<template #footer>
<div
class="bg-z-fill-quaternary"
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid min-h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
>
<span
v-if="!isCollapsed"
@@ -626,7 +250,7 @@ onBeforeUnmount(() => {
{{ t('navigation.online') }}
</span>
<div class="flex shrink-0 items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<div :class="isCollapsed ? 'grid justify-items-center gap-1' : 'flex shrink-0 items-center gap-1 [&_button]:max-w-8'">
<ZIconButton
v-if="isCollapsed && hasUpdate"
type="success" variant="plain"
@@ -674,6 +298,6 @@ onBeforeUnmount(() => {
</ZIconButton>
</div>
</div>
</div>
</aside>
</template>
</ZSidebar>
</template>
@@ -318,7 +318,7 @@ watch(
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3.5 gap-y-3 rounded-z bg-z-warning-container px-4 py-5 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<span class="grid size-9 place-items-center rounded-z bg-z-warning-container-active text-z-warning-on-container" aria-hidden="true">
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-[18px]" />
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-4.5" />
</span>
<p class="m-0 min-w-0 wrap-anywhere text-z font-bold leading-tight text-z-warning-on-container">
{{ updateInfo.warning }}
@@ -393,7 +393,7 @@ watch(
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-z-fill-quaternary px-2 text-z-xs leading-none font-bold text-z-text-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
:title="updateStreamError || undefined"
>
<i
class="size-1.5 rounded-full"
+3 -2
View File
@@ -69,14 +69,15 @@ watch(
<template>
<div class="relative flex h-dvh overflow-hidden bg-z-bg-layout" :inert="mobileSidebarOpen">
<AppSidebar
:collapsed="sidebarCollapsed"
:collapse="sidebarCollapsed"
@toggle="toggleSidebar"
@open-system-update="systemUpdateOpen = true"
/>
<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" />
+15 -2
View File
@@ -5,6 +5,7 @@ import type {
ThemeColorId,
ThemeCustomization,
ThemeMode,
ThemePresetId,
} from '@codex-proxy/ui/theme'
import { normalizeHexColor } from '@codex-proxy/ui'
@@ -13,9 +14,11 @@ import {
createTheme,
defaultThemeColorId,
defaultThemeMode,
defaultThemePresetId,
defaultThemePrimaryColor,
isThemeColorId,
isThemeMode,
isThemePresetId,
normalizeThemeCustomization,
resolveThemeScheme,
} from '@codex-proxy/ui/theme'
@@ -30,6 +33,7 @@ interface ThemeTransitionOrigin {
}
interface ThemeConfigurationInput {
preset: ThemePresetId
mode: ThemeMode
color: ThemeColorId
customColor: string
@@ -49,6 +53,7 @@ export const useThemeStore = defineStore(
'theme',
() => {
const themeMode = shallowRef<ThemeMode>(defaultThemeMode)
const themePreset = shallowRef<ThemePresetId>(defaultThemePresetId)
const themeColor = shallowRef<ThemeColorId>(defaultThemeColorId)
const customThemeColor = shallowRef(defaultThemePrimaryColor)
const themeCustomization = ref<ThemeCustomization>({})
@@ -64,6 +69,7 @@ export const useThemeStore = defineStore(
const resolvedTheme = computed(() =>
createTheme({
scheme: effectiveTheme.value,
preset: themePreset.value,
colorId: themeColor.value,
primaryColor: customThemeColor.value,
customization: themeCustomization.value,
@@ -87,6 +93,8 @@ export const useThemeStore = defineStore(
}
function normalizePersistedTheme(): void {
if (!isThemePresetId(themePreset.value))
themePreset.value = defaultThemePresetId
if (!isThemeMode(themeMode.value))
themeMode.value = defaultThemeMode
if (!isThemeColorId(themeColor.value))
@@ -237,10 +245,11 @@ export const useThemeStore = defineStore(
event?: MouseEvent,
): boolean {
const customColor = normalizeHexColor(configuration.customColor)
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
if (!isThemePresetId(configuration.preset) || !isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
return false
requestThemeTransition(event)
themePreset.value = configuration.preset
themeMode.value = configuration.mode
themeColor.value = configuration.color
customThemeColor.value = customColor
@@ -251,6 +260,7 @@ export const useThemeStore = defineStore(
function resetTheme(event?: MouseEvent): void {
const unchanged
= themeMode.value === defaultThemeMode
&& themePreset.value === defaultThemePresetId
&& themeColor.value === defaultThemeColorId
&& customThemeColor.value === defaultThemePrimaryColor
&& Object.keys(themeCustomization.value).length === 0
@@ -260,6 +270,7 @@ export const useThemeStore = defineStore(
}
requestThemeTransition(event)
themeMode.value = defaultThemeMode
themePreset.value = defaultThemePresetId
themeColor.value = defaultThemeColorId
customThemeColor.value = defaultThemePrimaryColor
themeCustomization.value = {}
@@ -288,6 +299,7 @@ export const useThemeStore = defineStore(
return {
themeMode,
themePreset,
themeColor,
customThemeColor,
themeCustomization,
@@ -308,6 +320,7 @@ export const useThemeStore = defineStore(
key: 'codex-proxy-rs-theme',
pick: [
'themeMode',
'themePreset',
'themeColor',
'customThemeColor',
'themeCustomization',
@@ -317,5 +330,5 @@ export const useThemeStore = defineStore(
)
function themeSignature(theme: Theme): string {
return `${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
return `${theme.preset}:${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
}
+2 -2
View File
@@ -213,7 +213,7 @@ const {
</script>
<template>
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<div class="flex min-h-0 w-full flex-col xl:h-[calc(100dvh-3rem)] xl:flex-none!">
<ZPageHeader
:title="t('navigation.accounts')"
:description="t('accounts.page.description')"
@@ -222,7 +222,7 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<ZCard
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
class="mt-4 flex flex-col xl:min-h-80 xl:flex-1"
>
<template #header>
<AccountFilters
@@ -163,7 +163,7 @@ const authProtocol = computed(() => {
const runtimeEnvironment = computed(() => {
const target = profile.value?.target
if (!target)
return { primary: '—', details: [] as string[], title: '—' }
return { primary: '—', details: [] as string[] }
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
const primary = [target.osType, target.osVersion].filter(present).join(' ')
@@ -171,7 +171,6 @@ const runtimeEnvironment = computed(() => {
return {
primary: primary || '—',
details,
title: [primary, ...details].filter(Boolean).join(' · '),
}
})
@@ -238,13 +237,12 @@ watch(
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
<strong
:aria-label="profile.version"
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
:title="profile.version"
class="inline-flex max-w-full min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 font-mono leading-none tabular-nums"
>
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-z-text">
{{ versionParts.release }}
</span>
<span v-if="versionParts.prerelease" class="truncate text-z-sm font-bold text-z-text-secondary">
<span v-if="versionParts.prerelease" class="text-z-sm font-bold wrap-anywhere text-z-text-secondary">
{{ versionParts.prerelease }}
</span>
</strong>
@@ -263,10 +261,7 @@ watch(
<ShieldCheck class="size-3.25 text-z-text-tertiary" />
{{ t('dashboard.clientIdentity.authProtocol') }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
:title="authProtocol"
>
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
{{ authProtocol }}
</dd>
</div>
@@ -276,10 +271,7 @@ watch(
<Monitor class="size-3.25 text-z-text-tertiary" />
{{ profile.provider === 'openai' ? t('dashboard.clientIdentity.emulatedEnvironment') : t('dashboard.clientIdentity.environment') }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
:title="runtimeEnvironment.title"
>
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
{{ runtimeEnvironment.primary }}
<span v-if="runtimeEnvironment.details.length" class="text-z-xs font-emphasis text-z-text-secondary">
<template v-for="detail in runtimeEnvironment.details" :key="detail"> · {{ detail }} </template>
@@ -292,10 +284,7 @@ watch(
<Terminal class="size-3.25 text-z-text-tertiary" />
{{ attribute.label }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text"
:title="attribute.value"
>
<dd class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text">
{{ attribute.value }}
</dd>
</div>
@@ -54,7 +54,7 @@ const metrics = computed(() => [
</ZMotionIcon>
<span class="sr-only">{{ t('usage.summary.consumeTokens') }}</span>
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text" :title="formatCompactNumber(summary.totalTokens)">
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text">
<AnimatedMetricValue :value="formatInteger(summary.totalTokens)" :raw-value="summary.totalTokens" :formatter="formatInteger" />
</strong>
<span class="shrink-0 font-mono text-z-sm leading-none whitespace-nowrap text-z-text-tertiary">≈ {{ formatCompactNumber(summary.totalTokens) }}</span>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { PluginManagementView } from '@/api'
import { ZButton, ZEmpty, ZTag } from '@codex-proxy/ui'
import { ZButton, ZCard, ZEmpty, ZTag } from '@codex-proxy/ui'
import { Blocks, ExternalLink } from '@lucide/vue'
import { computed } from 'vue'
@@ -40,10 +40,12 @@ function openView(view: PluginManagementView) {
/>
<div v-else class="grid content-start gap-3 overflow-y-auto sm:grid-cols-2 xl:grid-cols-3">
<article
<ZCard
v-for="view in pageViews"
:key="view.target.instanceId"
class="flex min-w-0 flex-col gap-3 rounded-z-card bg-z-bg-container p-4 shadow-z-card"
as="article"
padding="compact"
class="flex min-w-0 flex-col gap-3"
>
<div class="flex min-w-0 items-start gap-3">
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-z bg-z-primary-container text-z-primary-on-container">
@@ -72,7 +74,7 @@ function openView(view: PluginManagementView) {
</template>
{{ t('plugins.extensions.openPage') }}
</ZButton>
</article>
</ZCard>
</div>
</div>
</template>
@@ -2,7 +2,7 @@
import type { PricingRow } from './model'
import type { ModelPricing, PricingChange } from '@/api'
import { ZConfirmDialog, ZPagination } from '@codex-proxy/ui'
import { ZCard, ZConfirmDialog, ZPagination } from '@codex-proxy/ui'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingBatchModal from './PricingBatchModal.vue'
@@ -52,7 +52,7 @@ function requestDelete(row: PricingRow) {
</script>
<template>
<section class="flex min-h-0 min-w-0 flex-col rounded-z-card bg-z-bg-container p-3 shadow-z-card sm:p-4" :aria-label="t('settings.page.tabs.pricing')">
<ZCard padding="none" class="flex min-h-0 min-w-0 flex-col p-3 sm:p-4" :aria-label="t('settings.page.tabs.pricing')">
<PricingToolbar
v-model:search="search"
v-model:provider="provider"
@@ -77,5 +77,5 @@ function requestDelete(row: PricingRow) {
</ZConfirmDialog>
<PricingBatchModal v-model="batchOpen" :rows="targets" :reset="batchReset" :saving="saving" @confirm="saveBatch" />
<PricingSyncModal v-model="syncOpen" :preview="preview" :catalog="syncCatalog" :saving="saving" @confirm="confirmSync" />
</section>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ZButton, ZInput, ZPageHeader, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ZButton, ZCard, ZInput, ZPageHeader, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { Check, Eye, Pencil, RotateCcw, Save, Search, Undo2 } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
@@ -28,7 +28,9 @@ const {
previewStyle,
modificationCount,
dirty,
selectPreset,
selectMaterial,
applyProfile,
resetPrimaryColor,
setPrimaryColor,
setMode,
setSeedColor,
@@ -54,13 +56,6 @@ const scopeOptions = computed(() => ([
},
]))
function switchScope(value: string) {
scope.value = value === 'component'
? 'component'
: 'global'
mobilePreviewOpen.value = false
}
function saveTheme(event: MouseEvent) {
if (!save(event)) {
ZNotification.error({
@@ -92,14 +87,7 @@ function saveTheme(event: MouseEvent) {
<template #actions>
<div class="flex max-w-[100cqw] flex-wrap items-center justify-end gap-2">
<ZSegmented
:model-value="scope"
:aria-label="t('appearance.settings.themeEditingLevel')"
:options="scopeOptions"
size="small"
@update:model-value="switchScope"
/>
<ZButton size="small" class="min-[768px]:hidden" @click="mobilePreviewOpen = !mobilePreviewOpen">
<ZButton size="small" class="md:hidden" @click="mobilePreviewOpen = !mobilePreviewOpen">
<template #icon>
<Pencil v-if="mobilePreviewOpen" class="size-3.5" />
<Eye v-else class="size-3.5" />
@@ -130,30 +118,38 @@ function saveTheme(event: MouseEvent) {
</ZPageHeader>
<section
class="theme-workbench isolate mt-5 overflow-hidden rounded-z-card bg-z-bg-layout shadow-z-card xl:flex xl:min-h-0 xl:flex-1 xl:flex-col"
class="theme-workbench isolate mt-5 overflow-hidden xl:flex xl:min-h-0 xl:flex-1 xl:flex-col"
:aria-label="t('appearance.settings.themeEditor')"
>
<div
class="theme-workbench-body grid min-h-180 gap-3 xl:min-h-0 xl:flex-1 xl:overflow-hidden xl:grid-cols-[370px_minmax(0,1fr)]"
>
<aside
class="h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden rounded-z-lg bg-z-bg-container pt-3 pr-0 pb-3 pl-3 shadow-z-secondary min-[768px]:grid"
<ZCard
as="aside"
padding="none"
class="h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 py-3 md:grid"
:class="mobilePreviewOpen ? 'hidden' : 'grid'"
:aria-label="t('appearance.settings.themeTokenEditingPanel')"
>
<ZInput
v-model="query"
class="pr-3"
:aria-label="t('appearance.settings.searchToken')"
:placeholder="t('appearance.settings.searchPlaceholder')"
>
<template #prefix>
<Search class="size-4" />
</template>
</ZInput>
<div class="grid gap-3 px-3">
<ZSegmented
v-model="scope"
:aria-label="t('appearance.settings.themeEditingLevel')"
:options="scopeOptions"
/>
<ZInput
v-model="query"
:aria-label="t('appearance.settings.searchToken')"
:placeholder="t('appearance.settings.searchPlaceholder')"
>
<template #prefix>
<Search class="size-4" />
</template>
</ZInput>
</div>
<ZScrollbar class="h-full min-h-0">
<div class="pr-4">
<div class="px-3">
<ThemeGlobalTokenPanel
v-if="scope === 'global'"
v-model:category="globalCategory"
@@ -161,7 +157,9 @@ function saveTheme(event: MouseEvent) {
:resolved="resolvedPreview"
:query="query"
@mode="setMode"
@preset="selectPreset"
@material="selectMaterial"
@profile="applyProfile"
@reset-primary="resetPrimaryColor"
@primary="setPrimaryColor"
@seed-color="setSeedColor"
@seed-number="setSeedNumber"
@@ -180,9 +178,9 @@ function saveTheme(event: MouseEvent) {
/>
</div>
</ZScrollbar>
</aside>
</ZCard>
<div class="min-h-0 min-[768px]:block" :class="mobilePreviewOpen ? 'block' : 'hidden'">
<div class="min-h-0 md:block" :class="mobilePreviewOpen ? 'block' : 'hidden'">
<ThemeEditorPreview v-model="preview" class="h-full" :theme="previewTheme" :style="previewStyle" />
</div>
</div>
@@ -374,12 +374,6 @@ function numericTokenValue(name: ThemeTokenName) {
</section>
<section class="grid gap-2" :aria-label="`${activeComponent.label} Component Token`">
<div class="rounded-z-lg bg-z-bg-container px-3 py-3 shadow-z-tertiary">
<p class="m-0 text-[10px] leading-normal font-emphasis text-z-text-secondary">
{{ t('appearance.componentTokens.description', { label: activeComponent.label }) }}
</p>
</div>
<template v-for="token in visibleTokens" :key="token.name">
<ThemeColorTokenField
v-if="token.kind === 'color'"
@@ -4,7 +4,7 @@ import type { ThemeScheme } from '@codex-proxy/ui/theme'
import type { CSSProperties } from 'vue'
import type { ThemeEditorPreview } from '../composables/useThemeEditor'
import { ZSegmented } from '@codex-proxy/ui'
import { ZCard, ZSegmented } from '@codex-proxy/ui'
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -67,13 +67,13 @@ const previewOptions = computed(() => ([
<template>
<section class="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden" :aria-label="t('appearance.preview.themeRealTimePreview')">
<header class="shrink-0 flex flex-wrap items-center justify-between gap-3 rounded-z-lg bg-z-bg-container px-3 py-2.5 shadow-z-tertiary">
<ZCard as="header" padding="none" class="shrink-0 flex flex-wrap items-center justify-between gap-3 px-3 py-2.5">
<div>
<strong class="block text-z-sm font-heavy text-z-text">{{ t('appearance.preview.livePreview') }}</strong>
<span class="mt-0.5 block text-[9px] font-emphasis text-z-text-quaternary">{{ t('appearance.preview.description') }}</span>
</div>
<ZSegmented v-model="preview" :aria-label="t('appearance.preview.previewType')" size="small" :options="previewOptions" />
</header>
</ZCard>
<ThemePreviewCanvas
class="h-full min-h-0"
@@ -1,16 +1,18 @@
<script setup lang="ts">
import type { Theme, ThemeColorPreset, ThemeColorPresetId, ThemeMode, ThemeSeedOverrides } from '@codex-proxy/ui/theme'
import type { Theme, ThemeColorPresetId, ThemeMode, ThemePresetId, ThemeSeedOverrides } from '@codex-proxy/ui/theme'
import type { ThemeEditorDraft, ThemeEditorGlobalCategory } from '../composables/useThemeEditor'
import { ZSegmented } from '@codex-proxy/ui'
import { createTheme, themeColorPresets } from '@codex-proxy/ui/theme'
import { Check, ChevronDown, Laptop, Moon, Sun } from '@lucide/vue'
import { createThemeSeedTokens, themeColorPresets, themePresets, themeProfiles } from '@codex-proxy/ui/theme'
import { Check, ChevronDown, Laptop, Moon, PanelsTopLeft, Sparkles, Sun } from '@lucide/vue'
import { isEqual } from 'es-toolkit'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { uiLocale } from '@/i18n/ui'
import sceneArtwork from './theme-scenes.svg?no-inline'
import ThemeColorTokenField from './ThemeColorTokenField.vue'
import ThemeNumberTokenField from './ThemeNumberTokenField.vue'
@@ -22,7 +24,9 @@ const props = defineProps<{
const emit = defineEmits<{
mode: [value: ThemeMode]
preset: [value: ThemeColorPresetId]
material: [value: ThemePresetId]
resetPrimary: []
profile: [value: ThemeColorPresetId]
primary: [value: string]
seedColor: [
key: Exclude<
@@ -43,11 +47,41 @@ const { t } = useI18n({ useScope: 'global' })
const category = defineModel<ThemeEditorGlobalCategory>('category', { required: true })
const derivedOpen = shallowRef(false)
const localizedPresets = computed(() => themeColorPresets.map(preset => ({
...preset,
...uiLocale.value.theme[preset.id],
const profiles = themeColorPresets.map((preset) => {
const options = { ...themeProfiles[preset.id], colorId: preset.id }
const light = createThemeSeedTokens({ ...options, scheme: 'light' })
return {
...preset,
paper: light.colorBgBase,
accent: light.colorWarning,
ink: createThemeSeedTokens({ ...options, scheme: 'dark' }).colorBgBase,
}
})
const localizedPresets = computed(() => profiles.map(p => ({
...p,
...uiLocale.value.theme[p.id],
})))
const activeProfile = computed(() => {
const { color, preset, customization } = props.draft
if (color !== 'custom' && themeProfiles[color].preset === preset && isEqual(themeProfiles[color].customization, customization))
return color
return undefined
})
const colorPresets = themeColorPresets.map(preset => preset.seed)
const themePresetDetails = computed(() => ({
default: {
label: t('appearance.themePresets.default.label'),
icon: PanelsTopLeft,
},
soft: {
label: t('appearance.themePresets.soft.label'),
icon: Sparkles,
},
}))
const themePresetOptions = computed(() => themePresets.map(preset => ({
...themePresetDetails.value[preset.id],
value: preset.id,
})))
const categoryOptions = computed(() => ([
{
label: t('common.fields.color'),
@@ -79,17 +113,6 @@ const modeOptions = computed(() => ([
icon: Moon,
},
]))
const presetSwatchesById = new Map(
themeColorPresets.map(preset => [
preset.id,
[
createTheme({ scheme: 'light', colorId: preset.id }).seedTokens.colorBgBase,
preset.seed,
createTheme({ scheme: 'dark', colorId: preset.id }).seedTokens.colorBgBase,
],
]),
)
const semanticColorFields = computed(() =>
[
{
@@ -158,10 +181,6 @@ function matches(value: string) {
function seedOverridden(key: keyof ThemeSeedOverrides) {
return Object.hasOwn(props.draft.customization.seed ?? {}, key)
}
function presetSwatches(preset: ThemeColorPreset) {
return presetSwatchesById.get(preset.id) ?? [preset.seed]
}
</script>
<template>
@@ -177,50 +196,64 @@ function presetSwatches(preset: ThemeColorPreset) {
/>
</div>
<section v-if="matches(t('appearance.themePresets.title')) || localizedPresets.some(p => matches(p.label))" class="grid gap-2">
<h3 class="m-0 px-1 text-z font-heavy text-z-text">
{{ t('appearance.themePresets.title') }}
</h3>
<div class="grid grid-cols-2 gap-2" role="radiogroup" :aria-label="t('appearance.themePresets.title')">
<button
v-for="preset in localizedPresets"
:key="preset.id"
type="button"
role="radio"
class="relative flex min-h-20 flex-col items-stretch justify-between gap-2 rounded-z-lg bg-z-fill-quaternary p-2.5 text-left outline-none transition-[background-color,box-shadow] duration-150 hover:bg-z-bg-text-hover focus-visible:ring-2 focus-visible:ring-z-control-outline"
:class="activeProfile === preset.id ? 'bg-z-control-item-bg-active shadow-z-tertiary' : undefined"
:aria-checked="activeProfile === preset.id"
:aria-label="`${preset.label}: ${preset.description}`"
:title="preset.description"
@click="emit('profile', preset.id)"
>
<svg
class="block h-11 w-full overflow-hidden rounded-z-sm"
:style="{ 'color': preset.seed, '--scene-paper': preset.paper, '--scene-ink': preset.ink, '--scene-accent': preset.accent }"
viewBox="0 0 160 44"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<use :href="`${sceneArtwork}#${preset.id}`" width="160" height="44" />
</svg>
<span class="min-w-0 pr-4">
<strong class="block text-z-xs font-heavy text-z-text">{{ preset.label }}</strong>
<span class="mt-0.5 block truncate text-[9px] font-emphasis text-z-text-quaternary">
{{ preset.description }}
</span>
</span>
<Check
v-if="activeProfile === preset.id"
class="absolute right-2 bottom-2 size-3 text-z-primary-text"
stroke-width="3"
/>
</button>
</div>
</section>
<section v-if="matches(t('appearance.themePresets.material'))" class="grid gap-2">
<h3 class="m-0 px-1 text-z font-heavy text-z-text">
{{ t('appearance.themePresets.material') }}
</h3>
<ZSegmented
:model-value="draft.preset"
:aria-label="t('appearance.themePresets.material')"
:options="themePresetOptions"
class="w-full"
@update:model-value="emit('material', $event as ThemePresetId)"
/>
</section>
<ZSegmented v-model="category" :aria-label="t('appearance.globalTokens.globalTokenClassification')" :options="categoryOptions" class="w-full" />
<template v-if="category === 'color'">
<section v-if="matches(t('appearance.globalTokens.brandColorLabel'))" class="grid gap-2" aria-labelledby="theme-preset-title">
<div class="flex items-center justify-between px-1">
<h3 id="theme-preset-title" class="m-0 text-z font-heavy text-z-text">
{{ t('appearance.globalTokens.brandColor') }}
</h3>
<code class="font-mono text-[10px] text-z-text-quaternary">colorPrimary</code>
</div>
<div class="grid grid-cols-2 gap-2" role="radiogroup" :aria-label="t('appearance.globalTokens.presetThemes')">
<button
v-for="preset in localizedPresets"
:key="preset.id"
type="button"
role="radio"
class="relative flex min-h-20 flex-col items-stretch justify-between gap-2 rounded-z-lg bg-z-fill-quaternary p-2.5 text-left outline-none transition-[background-color,box-shadow] duration-150 hover:bg-z-bg-text-hover focus-visible:ring-2 focus-visible:ring-z-control-outline"
:class="draft.color === preset.id ? 'bg-z-control-item-bg-active shadow-z-tertiary' : undefined"
:aria-checked="draft.color === preset.id"
:aria-label="`${preset.label}: ${preset.description}`"
:title="preset.description"
@click="emit('preset', preset.id)"
>
<span class="flex h-4 overflow-hidden rounded-full shadow-z-tertiary">
<span
v-for="swatch in presetSwatches(preset)"
:key="swatch"
class="flex-1"
:style="{ backgroundColor: swatch }"
/>
</span>
<span class="min-w-0 pr-4">
<strong class="block text-z-xs font-heavy text-z-text">{{ preset.label }}</strong>
<span class="mt-0.5 block truncate text-[9px] font-emphasis text-z-text-quaternary">
{{ preset.description }}
</span>
</span>
<Check
v-if="draft.color === preset.id"
class="absolute right-2 bottom-2 size-3 text-z-primary-text"
stroke-width="3"
/>
</button>
</div>
<section v-if="matches(t('appearance.globalTokens.brandColorLabel'))" class="grid gap-2">
<ThemeColorTokenField
:label="t('appearance.globalTokens.brandMainColor')"
token="colorPrimary"
@@ -229,7 +262,7 @@ function presetSwatches(preset: ThemeColorPreset) {
:presets="colorPresets"
:overridden="draft.color === 'custom'"
@change="emit('primary', $event)"
@reset="emit('preset', 'relay-blue')"
@reset="emit('resetPrimary')"
/>
</section>
@@ -323,11 +356,6 @@ function presetSwatches(preset: ThemeColorPreset) {
</section>
<section v-else class="grid gap-2" :aria-label="t('appearance.globalTokens.styleToken')">
<div
class="rounded-z-lg bg-z-primary-container px-3 py-3 text-[10px] leading-normal font-emphasis text-z-primary-on-container"
>
{{ t('appearance.globalTokens.surfaceDescription') }}
</div>
<ThemeNumberTokenField
:label="t('appearance.globalTokens.universalFillet')"
token="borderRadius"
@@ -45,7 +45,7 @@ const boardStyle = computed<CSSProperties>(() => ({
<template>
<div
ref="viewport"
class="@container/preview relative min-h-0 overflow-hidden rounded-z-lg bg-z-bg-layout bg-[radial-gradient(circle,color-mix(in_srgb,var(--z-color-text-quaternary)_24%,transparent)_1px,transparent_1px)] bg-size-[18px_18px] shadow-z-secondary select-none"
class="@container/preview relative min-h-0 overflow-hidden rounded-z-lg bg-z-bg-layout bg-[radial-gradient(circle,color-mix(in_srgb,var(--z-color-text-quaternary)_24%,transparent)_1px,transparent_1px)] bg-size-[18px_18px] select-none"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
role="application"
tabindex="0"
@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="relay-blue" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
<path fill="var(--scene-ink)" d="M0 0h160v44H0z"/>
<path fill="currentColor" opacity=".3" d="M0 34q25-4 56-1t58-1q25-2 46 0v12H0z"/>
<path fill="none" stroke="currentColor" opacity=".7" d="m14 16 12-8 13 6 15-7"/>
<g fill="var(--scene-paper)">
<circle cx="14" cy="16" r="1.1"/><circle cx="26" cy="8" r="1.6"/><circle cx="39" cy="14" r="1.2"/>
<path d="m54 3 1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/>
<circle cx="85" cy="11" r=".8"/><circle cx="142" cy="15" r="1"/>
<path d="M107 16v13h-8z"/>
</g>
<path fill="currentColor" d="m109 20 5 9h-5zm-13 11h21l-4 3h-12z"/>
<path fill="none" stroke="currentColor" opacity=".65" stroke-linecap="round" d="m85 36 14-.5m5 3 6-.5"/>
</symbol>
<symbol id="deep-teal" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
<g fill="currentColor">
<path opacity=".18" d="m0 18 12-5 9 1 15-9 7 2 6-3 14 12 8-3 13 6 16-9 8 2 12-6 17 12 9-3 14 4v5H0z"/>
<path opacity=".38" d="m0 22 16-6 7 1 10-8 4 2 4-3 10 9 9 2 8 5-33-2-16 3-19 1z"/>
<path opacity=".61" d="m0 26 9-7 4 1 8-9 4 2 6-6 3 6 7 3 2 3 9 3-17-2-14 4z"/>
</g>
<path fill="var(--scene-ink)" d="m0 32 10-3 9 1 8-3 10 1 11 4 14 2-25 1-17-2-20 4z"/>
<g fill="none" stroke="var(--scene-ink)" stroke-linecap="round">
<path stroke-width="1.1" d="m21 29-1-12m1 8-4-4m3 1 4-5m-8 12-2-8m2 5 2-4"/>
<path opacity=".36" d="m3 39 26-2m10 0 8 .4m58-3 15 .2"/>
</g>
<path fill="var(--scene-ink)" d="M100 30q5 2 16 0l-3 2h-9z"/>
<path fill="none" stroke="var(--scene-ink)" stroke-linecap="round" d="m110 30-1-2-2 2m2-1 6 4"/>
</symbol>
<symbol id="signal-violet" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
<path fill="currentColor" d="M0 5l57 10v29H0zM74 18l26 5v21H74zM119 24l13 2v18h-13z"/>
<path fill="var(--scene-ink)" opacity=".57" d="M57 15l10-4v33H57zM100 23l5-2v23h-5zM132 26l4-1v19h-4z"/>
<path fill="currentColor" opacity=".28" d="M0 44l160-13v13z"/>
<circle cx="143" cy="10" r="4" fill="currentColor"/>
</symbol>
<symbol id="graphite" viewBox="0 0 160 44" preserveAspectRatio="xMidYMid slice">
<path fill="var(--scene-paper)" d="M0 0h160v44H0z"/>
<path fill="currentColor" d="M0 33h160v11H0zM26 21h5v16h-5zm21 0h5v16h-5zm22 0h5v16h-5zm23 0h5v16h-5zm22 0h5v16h-5zm20 0h5v16h-5z"/>
<path fill="var(--scene-ink)" d="M22 18h120l-5 5H28z"/>
<path fill="var(--scene-accent)" d="M12 14q12 6 28-1 8-3 15-11h55q7 8 15 11 14 6 27 1-4 7-19 7H32q-15 0-20-7z"/>
<path fill="none" stroke="var(--scene-ink)" stroke-width="1.4" d="M12 15q8 7 24 6h94q15 1 22-6M54 2h57"/>
<path fill="none" stroke="var(--scene-paper)" opacity=".5" d="m56 5-10 10m20-10-5 10m15-10-2 10m13-10 1 10m10-10 4 10m6-10 8 10"/>
<path fill="var(--scene-accent)" d="M0 33h160v2H0z"/>
<path fill="var(--scene-ink)" opacity=".25" d="M0 40h160v4H0z"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -4,6 +4,7 @@ import type {
ThemeComponentOverrides,
ThemeCustomization,
ThemeMode,
ThemePresetId,
ThemeScheme,
ThemeSeedOverrides,
ThemeTokenName,
@@ -15,10 +16,13 @@ import {
createThemeVars,
defaultThemeColorId,
defaultThemeMode,
defaultThemePresetId,
defaultThemePrimaryColor,
isThemeColorId,
isThemeMode,
isThemePresetId,
normalizeThemeCustomization,
themeProfiles,
} from '@codex-proxy/ui/theme'
import { storeToRefs } from 'pinia'
@@ -37,6 +41,7 @@ export type ThemeEditorComponent
| 'layout'
export interface ThemeEditorDraft {
preset: ThemePresetId
mode: ThemeMode
color: ThemeColorId
customColor: string
@@ -51,6 +56,7 @@ export function useThemeEditor() {
const themeStore = useThemeStore()
const {
themeMode,
themePreset,
themeColor,
customThemeColor,
themeCustomization,
@@ -68,6 +74,7 @@ export function useThemeEditor() {
const resolvedPreview = computed(() => createTheme({
scheme: previewTheme.value,
preset: draft.value.preset,
colorId: draft.value.color,
primaryColor: draft.value.customColor,
customization: draft.value.customization,
@@ -82,6 +89,7 @@ export function useThemeEditor() {
watch(
[
themeMode,
themePreset,
themeColor,
customThemeColor,
themeCustomization,
@@ -104,6 +112,7 @@ export function useThemeEditor() {
function createStoreDraft(): ThemeEditorDraft {
return {
preset: themePreset.value,
mode: themeMode.value,
color: themeColor.value,
customColor: customThemeColor.value,
@@ -111,13 +120,25 @@ export function useThemeEditor() {
}
}
function selectPreset(color: ThemeColorPresetId) {
function selectMaterial(preset: ThemePresetId) {
draft.value = { ...draft.value, preset }
}
function applyProfile(color: ThemeColorPresetId) {
const { preset, customization } = themeProfiles[color]
draft.value = {
...draft.value,
color,
preset,
customColor: defaultThemePrimaryColor,
customization: normalizeThemeCustomization(customization),
}
}
function resetPrimaryColor() {
draft.value = { ...draft.value, color: defaultThemeColorId, customColor: defaultThemePrimaryColor }
}
function setPrimaryColor(color: string) {
const normalized = normalizeHexColor(color)
if (!normalized)
@@ -217,6 +238,7 @@ export function useThemeEditor() {
function restoreDefaults() {
draft.value = {
preset: defaultThemePresetId,
mode: defaultThemeMode,
color: defaultThemeColorId,
customColor: defaultThemePrimaryColor,
@@ -272,7 +294,9 @@ export function useThemeEditor() {
previewStyle,
modificationCount,
dirty,
selectPreset,
selectMaterial,
applyProfile,
resetPrimaryColor,
setPrimaryColor,
setMode,
setSeedColor,
@@ -290,9 +314,10 @@ export function useThemeEditor() {
function normalizeDraft(value: ThemeEditorDraft): ThemeEditorDraft | null {
const customColor = normalizeHexColor(value.customColor)
if (!isThemeMode(value.mode) || !isThemeColorId(value.color) || !customColor)
if (!isThemePresetId(value.preset) || !isThemeMode(value.mode) || !isThemeColorId(value.color) || !customColor)
return null
return {
preset: value.preset,
mode: value.mode,
color: value.color,
customColor,
@@ -319,6 +344,8 @@ function cloneDraft(value: ThemeEditorDraft): ThemeEditorDraft {
function countDraftChanges(saved: ThemeEditorDraft, draft: ThemeEditorDraft) {
let count = 0
if (saved.preset !== draft.preset)
count += 1
if (saved.mode !== draft.mode)
count += 1
if (saved.color !== draft.color || saved.customColor !== draft.customColor)
@@ -275,25 +275,25 @@ function costAmount(value: string | null, pointIndex: number) {
<div v-if="hasData && cost" class="grid grid-cols-2 gap-x-1 gap-y-3 rounded-xl bg-z-fill-quaternary/45 p-2 @min-[24rem]/cost:grid-cols-3 @min-[40rem]/cost:grid-cols-5">
<div class="grid min-w-0 gap-1 px-1.5">
<span class="text-[10px] font-bold text-z-text-quaternary" :title="t('usage.billing.totalCost')">{{ t('usage.cost.actualCost') }}</span>
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-text" :title="formatUsd(cost.estimatedCost)">
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-text">
{{ formatUsd(cost.estimatedCost) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="text-[10px] font-bold text-z-text-quaternary" :title="t('usage.cost.cacheSavings')">{{ t('usage.cost.savings') }}</span>
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-green-text" :title="formatUsd(cost.cacheSavings)">
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-green-text">
{{ formatUsd(cost.cacheSavings) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="text-[10px] font-bold text-z-text-quaternary" :title="t('usage.cost.tierPremium')">{{ t('usage.cost.premium') }}</span>
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-orange-text" :title="formatUsd(cost.tierPremium)">
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-orange-text">
{{ formatUsd(cost.tierPremium) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="text-[10px] font-bold text-z-text-quaternary" :title="t('usage.cost.perSuccessfulRequestHelp')">{{ t('usage.cost.perSuccessfulRequest') }}</span>
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-text" :title="formatUsd(cost.costPerSuccessfulRequest, true)">
<strong class="wrap-anywhere font-mono text-z-sm font-heavy tabular-nums text-z-text">
{{ formatUsd(cost.costPerSuccessfulRequest, true) }}
</strong>
</div>
@@ -28,7 +28,7 @@ function valueClass(item: { mono?: boolean, wrap?: boolean }) {
<dt :class="fieldLabelClass">
{{ item.label }}
</dt>
<dd :class="valueClass(item)" :title="displayValue(item.value)">
<dd :class="valueClass(item)" :title="item.wrap ? undefined : displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
+5 -11
View File
@@ -1,20 +1,14 @@
# v3.42.1
# v3.43.1
## 新增功能
- 插件管理页:打开外部链接前显示目标域名,可选择打开、复制链接或取消
- macOS:新增 Intel Mac 安装包,可下载 `darwin_amd64` 归档部署
## 优化改进
- OpenAI 调度:因账号队满或超时而换绑会话时,诊断记录会显示原因及换绑前后的账号
- 管理端:优化主内容区的滚动流畅度,减少系统设置等页面的滚动卡顿
## 问题修复
- 账号授权:修复使用设有 WebSocket 连接上限的已保存代理时,OpenAI 或 xAI 授权回调被误判为代理配置已变更的问题
- Codex 选模:上游只返回模型 ID 时,默认列表隐藏已识别的生图、实时和语音合成模型,仍可按 ID 直接调用
- 管理端:改善下拉菜单与浮层的定位和切换效果
## 升级须知
- 使用已保存代理且 WebSocket 连接上限非零时,升级或回退后请重新发起尚未完成的 OpenAI 或 xAI 授权;已保存的账号和凭据不受影响
- 自定义更新仓库:新版从 `main` 分支的 `release/version.yaml` 读取 `published` 通道表,发布工作流需要写入 `main` 的权限
- 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
+1 -1
View File
@@ -167,7 +167,7 @@ update_version_file "$version"
git add -- "$VERSION_FILE" "$NOTES_FILE" "$PLUGIN_DEPRECATIONS_FILE"
git commit \
-m "chore(release): prepare $tag" \
-m "chore: bump version to $version" \
-m "$SIGNATURE"
git tag -a "$tag" -m "$tag"
+5 -5
View File
@@ -1,7 +1,7 @@
version: 3.42.1
version: 3.43.1
published:
'3':
stable: 3.42.0
rc: 3.42.0
beta: 3.42.0
alpha: 3.42.0
stable: 3.43.0
rc: 3.43.0
beta: 3.43.0
alpha: 3.43.0