Compare commits

...
7 Commits
Author SHA1 Message Date
ZyyandCodex bf1c6211b8 chore: bump version to 3.1.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:40 +08:00
ZyyandCodex d388fa344a fix(openai): drop placeholder secondary quota windows
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:04 +08:00
ZyyandCodex eabb61b08c fix(openai): restore Codex Core app-server identity
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:01 +08:00
ZyyandCodex a5c3438927 fix(openai): isolate thread-spawn session affinity
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:29:53 +08:00
ZyyandCodex b6948542bb fix: align Codex Desktop HTTP identity
- send the official Desktop HTTP user agent across OpenAI upstream paths
- keep the audited model catalog version separate from the wire identity
- remove CLI release drift and clarify the dashboard client identity

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 18:36:03 +08:00
ZyyandCodex 0242bd3193 chore: bump version to 3.1.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 16:19:58 +08:00
ZyyandCodex 229ef48470 fix: harden backup verification and detail UI
- read every multipart backup payload to its requested length
- make remote verification failures diagnosable
- refine status details, loading icons, and attempt-chain presentation

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 16:18:53 +08:00
25 changed files with 697 additions and 102 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
}
#[test]
fn config_loader_should_reject_invalid_codex_core_version() {
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'"));
}
@@ -487,12 +487,35 @@ impl BackupTask {
);
}
}
Ok(_) => {
Ok(Some(remote)) => {
let size_matches = remote.size_bytes == size_bytes;
let sha256_matches = remote.sha256 == sha256;
warn!(
backup_id = %record.id,
expected_size_bytes = size_bytes,
remote_size_bytes = remote.size_bytes,
sha256_matches,
"备份远端对象校验不一致"
);
let _ = store.delete_object(&config, &record.object_key).await;
self.fail_task(
record,
code::REMOTE_VERIFICATION_FAILED,
"远端对象大小或校验值不一致",
match (size_matches, sha256_matches) {
(false, true) => "远端对象大小不一致",
(true, false) => "远端对象 sha256 校验值不一致",
(false, false) => "远端对象大小和 sha256 校验值均不一致",
(true, true) => "远端对象校验状态异常",
},
)
.await?;
}
Ok(None) => {
warn!(backup_id = %record.id, "上传完成后未找到远端对象");
self.fail_task(
record,
code::REMOTE_VERIFICATION_FAILED,
"上传完成后未找到远端对象",
)
.await?;
}
@@ -516,10 +539,17 @@ impl BackupTask {
error_code: &'static str,
message: impl Into<String>,
) -> Result<(), WorkerTaskError> {
let message = message.into();
warn!(
backup_id = %record.id,
error_code,
error_message = %message,
"备份任务失败"
);
let now = Utc::now();
let update = StatusTransitionUpdate {
error_code: Some(error_code.to_string()),
error_message: Some(message.into()),
error_message: Some(message),
completed_at: Some(now),
..Default::default()
};
+1 -1
View File
@@ -37,7 +37,7 @@ uuid = { version = "1.23.3", features = ["v4", "v7"] }
[dev-dependencies]
tempfile = "3.23"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "fs", "io-util", "process", "time"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "process", "time"] }
[lints]
workspace = true
+12 -7
View File
@@ -385,7 +385,13 @@ async fn upload_parts(
BackupError::new(code::S3_UPLOAD_FAILED, "上传并发闸门关闭".to_owned())
})?;
let offset = (part - 1) * (PART_SIZE as u64);
let bytes = read_part(&source, offset, PART_SIZE).await?;
let remaining = file_size.checked_sub(offset).ok_or_else(|| {
BackupError::new(code::S3_UPLOAD_FAILED, "分片偏移超出归档大小".to_owned())
})?;
let part_len = usize::try_from(remaining.min(PART_SIZE as u64)).map_err(|_| {
BackupError::new(code::S3_UPLOAD_FAILED, "分片大小溢出".to_owned())
})?;
let bytes = read_part(&source, offset, part_len).await?;
let part_number = i32::try_from(part).map_err(|_| {
BackupError::new(code::S3_UPLOAD_FAILED, "分片号溢出".to_owned())
})?;
@@ -450,20 +456,19 @@ async fn upload_parts(
.map_err(map_s3_error)
}
/// 从文件中读取一个分片(最多 `PART_SIZE` 字节)。
async fn read_part(source: &Path, offset: u64, max_len: usize) -> Result<Vec<u8>, BackupError> {
/// 从文件中读取指定长度的完整分片。
async fn read_part(source: &Path, offset: u64, len: usize) -> Result<Vec<u8>, BackupError> {
let mut file = tokio::fs::File::open(source)
.await
.map_err(|_| BackupError::new(code::S3_UPLOAD_FAILED, "打开暂存归档失败".to_owned()))?;
file.seek(SeekFrom::Start(offset))
.await
.map_err(|_| BackupError::new(code::S3_UPLOAD_FAILED, "定位暂存归档失败".to_owned()))?;
let mut buffer = vec![0_u8; max_len];
let n = file
.read(&mut buffer)
let mut buffer = vec![0_u8; len];
// Tokio 默认单次文件读取最多 2 MiB;分片必须读取完整,不能把短读当作末尾分片上传。
file.read_exact(&mut buffer)
.await
.map_err(|_| BackupError::new(code::S3_UPLOAD_FAILED, "读取暂存归档失败".to_owned()))?;
buffer.truncate(n);
Ok(buffer)
}
@@ -1,3 +1,4 @@
mod postgres;
mod redis;
mod s3;
mod support;
+141
View File
@@ -0,0 +1,141 @@
use std::io;
use chrono::Utc;
use gateway_admin::model::backup::{BackupObjectMetadata, BackupStorageConfig};
use gateway_admin::ports::backup::{BackupObjectStorePort, UploadObjectRequest};
use gateway_core::engine::CancellationToken;
use gateway_store::backup::s3::S3ObjectStoreAdapter;
use secrecy::SecretString;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::{TcpListener, TcpStream};
const PART_SIZE_ABOVE_TOKIO_DEFAULT: usize = 2 * 1024 * 1024 + 1;
#[tokio::test]
async fn upload_file_sends_full_part_beyond_tokio_default_buffer_limit() {
let directory = tempfile::tempdir().expect("create temporary directory");
let source = directory.path().join("archive.dump");
let expected = vec![b'x'; PART_SIZE_ABOVE_TOKIO_DEFAULT];
tokio::fs::write(&source, &expected)
.await
.expect("write temporary archive");
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind multipart probe server");
let endpoint = format!(
"http://{}",
listener.local_addr().expect("read listener address")
);
let server = tokio::spawn(serve_multipart_probe(listener));
let storage = BackupStorageConfig {
storage_revision: 1,
endpoint,
region: "auto".to_owned(),
bucket: "backup-bucket".to_owned(),
access_key_id: "test-access-key".to_owned(),
secret_access_key: SecretString::from("test-secret-key"),
prefix: "backups".to_owned(),
force_path_style: true,
};
let request = UploadObjectRequest {
object_key: "backups/archive.dump".to_owned(),
source,
metadata: BackupObjectMetadata::new(
"backup_test".to_owned(),
"a".repeat(64),
Utc::now(),
PART_SIZE_ABOVE_TOKIO_DEFAULT as u64,
),
cancellation: CancellationToken::new(),
};
S3ObjectStoreAdapter::new()
.upload_file(&storage, request)
.await
.expect("upload multipart archive");
let uploaded_len = server
.await
.expect("multipart probe task did not panic")
.expect("serve multipart probe");
assert_eq!(uploaded_len, PART_SIZE_ABOVE_TOKIO_DEFAULT);
}
async fn serve_multipart_probe(listener: TcpListener) -> io::Result<usize> {
let mut uploaded_len = None;
for _ in 0..3 {
let (mut stream, _) = listener.accept().await?;
let (request, body) = read_http_request(&mut stream).await?;
let response = if request.starts_with("POST ") && request.contains("uploads") {
xml_response(
"<InitiateMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><UploadId>probe-upload</UploadId></InitiateMultipartUploadResult>",
)
} else if request.starts_with("PUT ") && request.contains("partNumber=") {
uploaded_len = Some(body.len());
"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\netag: \"probe-part\"\r\n\r\n".to_owned()
} else if request.starts_with("POST ") && request.contains("uploadId=") {
xml_response(
"<CompleteMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><ETag>\"probe-part\"</ETag></CompleteMultipartUploadResult>",
)
} else {
return Err(io::Error::other("unexpected multipart probe request"));
};
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
}
uploaded_len.ok_or_else(|| io::Error::other("multipart probe did not receive an upload part"))
}
async fn read_http_request(stream: &mut TcpStream) -> io::Result<(String, Vec<u8>)> {
let mut bytes = Vec::new();
let header_end = loop {
if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
break position + 4;
}
let mut chunk = [0_u8; 8 * 1024];
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"multipart probe request ended before headers",
));
}
bytes.extend_from_slice(&chunk[..read]);
};
let headers = String::from_utf8_lossy(&bytes[..header_end]).into_owned();
let content_len = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or_default();
if headers
.to_ascii_lowercase()
.contains("expect: 100-continue")
{
stream.write_all(b"HTTP/1.1 100 Continue\r\n\r\n").await?;
}
while bytes.len() < header_end + content_len {
let mut chunk = [0_u8; 8 * 1024];
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"multipart probe request ended before body",
));
}
bytes.extend_from_slice(&chunk[..read]);
}
Ok((
headers,
bytes[header_end..header_end + content_len].to_vec(),
))
}
fn xml_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/xml\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
+13 -5
View File
@@ -194,22 +194,23 @@ impl ProviderAdmin for OpenAiAdminProvider {
let profile = self.profile.snapshot();
let release = self.desktop_release.snapshot();
let user_agent = profile.user_agent();
let client_identity = format!("{}; {}", profile.originator, profile.desktop_version);
let release = dashboard_desktop_release(&profile, release);
Some(DashboardWireProfile {
provider: self.provider_kind.as_str().to_owned(),
product: profile.originator,
version: profile.desktop_version,
build: Some(profile.desktop_build),
version: profile.codex_version,
build: None,
target: DashboardWireTarget {
os_type: profile.os_type,
os_type: dashboard_os_type(&profile.os_type),
os_version: profile.os_version,
arch: profile.arch,
terminal: profile.terminal,
},
user_agent,
attributes: vec![DashboardWireAttribute {
label: "Codex Core".to_owned(),
value: profile.codex_version,
label: "客户端标识".to_owned(),
value: client_identity,
}],
verified_at: Some(profile.verified_at),
release: Some(release),
@@ -694,6 +695,13 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
))
}
fn dashboard_os_type(os_type: &str) -> String {
match os_type {
"Mac OS" => "macOS".to_owned(),
_ => os_type.to_owned(),
}
}
fn dashboard_desktop_release(
profile: &CodexWireProfile,
snapshot: CodexDesktopReleaseSnapshot,
+13 -5
View File
@@ -278,6 +278,7 @@ impl CodexAuthSettings {
#[serde(deny_unknown_fields)]
pub struct CodexWireProfileConfig {
pub originator: String,
/// npm `@openai/codex` 启动版本;运行时发布任务会更新此画像字段。
pub codex_version: String,
pub desktop_version: String,
pub desktop_build: String,
@@ -292,10 +293,10 @@ impl Default for CodexWireProfileConfig {
fn default() -> Self {
Self {
originator: "Codex Desktop".to_owned(),
codex_version: "0.145.0".to_owned(),
desktop_version: "26.721.31836".to_owned(),
desktop_build: "5828".to_owned(),
os_type: "macOS".to_owned(),
codex_version: "0.146.0".to_owned(),
desktop_version: "26.727.51351".to_owned(),
desktop_build: "6119".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
@@ -355,7 +356,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
codex_version: value.codex_version,
desktop_version: value.desktop_version,
desktop_build: value.desktop_build,
os_type: value.os_type,
os_type: canonical_codex_os_type(value.os_type),
os_version: value.os_version,
arch: value.arch,
terminal: value.terminal,
@@ -364,6 +365,13 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
}
}
fn canonical_codex_os_type(os_type: String) -> String {
match os_type.as_str() {
"macOS" => "Mac OS".to_owned(),
_ => os_type,
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OpenAiConfigError {
#[error("OpenAI configuration field is invalid: {0}")]
@@ -430,7 +430,10 @@ impl CodexCredentialQuotaService {
.and_then(|observation| observation.quota)
.map(OpaqueProviderData::into_inner)
.unwrap_or_default();
let quota = confirmed_exhaustion_projection(existing, reset_at);
let quota = confirmed_exhaustion_projection(
normalize_quota_window_placeholders(existing),
reset_at,
);
let observed_at = SystemTime::now();
let outcome = self
.store
@@ -594,10 +597,12 @@ impl CodexCredentialQuotaService {
let refreshed_fact = refreshed_snapshot.fact();
let previous_reset_at = self.previous_confirmed_reset(account).await;
let now = SystemTime::now();
let object = value
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
let object = normalize_quota_window_placeholders(
value
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
);
let object = if account.availability() == AccountAvailability::QuotaExhausted
&& quota_success_state(
account.availability(),
@@ -927,10 +932,12 @@ impl CodexCredentialQuotaService {
let refreshed_fact = refreshed_snapshot.fact();
let previous_reset_at = self.previous_confirmed_reset(&account).await;
let now = SystemTime::now();
let object = value
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
let object = normalize_quota_window_placeholders(
value
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
);
let object = if account.availability() == AccountAvailability::QuotaExhausted
&& quota_success_state(
account.availability(),
@@ -1181,6 +1188,40 @@ fn merge_passive_quota(
quota
}
/// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。
///
/// 生产响应头可能携带 `used_percent=0`、零时长和空 reset 的占位。正常被动同步
/// 保留该响应事实,Admin 展示会将其隐藏;但主动 `/usage` 刷新或 402 确认投影若
/// 遇到这个对象,会把它写成 100%,从而显示并不存在的“次级额度”。因此这些
/// 非被动写入口在落库前移除无事实对象。带 reset、时长、正用量、触顶、未知或
/// 非法字段的次级窗口均完全按原有额度逻辑保留。
fn normalize_quota_window_placeholders(mut quota: Map<String, Value>) -> Map<String, Value> {
if let Some(rate_limit) = quota.get_mut("rate_limit").and_then(Value::as_object_mut) {
drop_secondary_window_placeholder(rate_limit);
}
quota
}
fn drop_secondary_window_placeholder(rate_limit: &mut Map<String, Value>) {
let placeholder = rate_limit
.get("secondary_window")
.and_then(Value::as_object)
.is_some_and(secondary_window_is_placeholder);
if placeholder {
rate_limit.remove("secondary_window");
}
}
fn secondary_window_is_placeholder(window: &Map<String, Value>) -> bool {
window.iter().all(|(field, value)| match field.as_str() {
"used_percent" => value
.as_f64()
.is_some_and(|used_percent| used_percent.is_finite() && used_percent == 0.0),
"limit_reached" => value.as_bool() == Some(false),
_ => false,
})
}
/// 用已确认的上游额度拒绝覆盖 core 限额投影。
///
/// 上游 usage 快照可能在结算期间短暂滞后为 98/99% 或 `allowed=true`;保留其他
+3 -1
View File
@@ -82,7 +82,7 @@ pub async fn initialize(
profile.clone(),
Arc::new(
OfficialCodexCliReleaseTransport::new()
.map_err(|_| OpenAiInitializeError::DesktopRelease)?,
.map_err(|_| OpenAiInitializeError::CliRelease)?,
),
));
let repository = CodexCredentialRepository::new(Arc::clone(&accounts));
@@ -268,6 +268,8 @@ pub enum OpenAiInitializeError {
Refresh,
#[error("OpenAI Desktop release service could not initialize")]
DesktopRelease,
#[error("OpenAI CLI release service could not initialize")]
CliRelease,
#[error("OpenAI worker plan is invalid")]
Worker,
}
@@ -14,21 +14,22 @@ use url::Url;
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
pub const CODEX_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest";
/// 官方制品画像检查周期。
/// 官方发布元数据检查周期。
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_APPCAST_BYTES: usize = 1024 * 1024;
const MAX_CLI_RELEASE_BYTES: usize = 64 * 1024;
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和初始 Desktop 版本。官方 appcast
/// 检查成功后,会仅同步 Desktop 版本和构建号。
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。npm CLI metadata
/// 与官方 appcast 检查成功后,分别同步 Core 版本和 Desktop 版本及构建号。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWireProfile {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// bundled Codex Core 版本;同时用于 `/codex/models?client_version=`。
/// npm `@openai/codex` 启动版本;用于 `/codex/models?client_version=` 与 UA。
pub codex_version: String,
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
pub desktop_version: String,
@@ -47,16 +48,18 @@ pub struct CodexWireProfile {
}
impl CodexWireProfile {
/// 按 Codex Core 的官方格式生成最终 User-Agent。
/// 按 bundled Core app-server 的官方格式生成最终 User-Agent。
pub fn user_agent(&self) -> String {
format!(
"{}/{} ({} {}; {}) {}",
"{}/{} ({} {}; {}) {} ({}; {})",
self.originator,
self.codex_version,
self.os_type,
self.os_version,
self.arch,
self.terminal,
self.originator,
self.desktop_version,
)
}
}
@@ -96,7 +99,7 @@ impl CodexWireProfileState {
profile.desktop_build = desktop_build.to_owned();
}
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时 Core 画像。
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时请求画像。
pub fn update_cli_release(&self, codex_version: &str) {
let mut profile = self
.profile
@@ -143,12 +146,12 @@ impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport {
}
if response
.content_length()
.is_some_and(|size| size > 64 * 1024)
.is_some_and(|size| size > MAX_CLI_RELEASE_BYTES as u64)
{
return Err(CodexCliReleaseError::ResponseTooLarge);
}
let bytes = response.bytes().await?;
if bytes.len() > 64 * 1024 {
if bytes.len() > MAX_CLI_RELEASE_BYTES {
return Err(CodexCliReleaseError::ResponseTooLarge);
}
let document: serde_json::Value = serde_json::from_slice(&bytes)
@@ -10,6 +10,8 @@ use sha2::{Digest, Sha256};
use crate::transport::protocol::responses::CodexResponsesRequest;
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
"authorization",
@@ -160,28 +162,52 @@ fn string_value(value: Option<&Value>) -> Option<String> {
pub(crate) fn derive_conversation_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
thread_spawn_conversation_anchor(request).or_else(|| {
request
.client_session_id
.as_deref()
.map(|value| ("session", value.to_owned()))
.or_else(|| {
request
.client_conversation_id
.as_deref()
.map(|value| ("conversation", value.to_owned()))
})
.or_else(|| {
request
.client_thread_id
.as_deref()
.map(|value| ("thread", value.to_owned()))
})
.or_else(|| {
request
.prompt_cache_key()
.map(|value| ("prompt-cache", value.to_owned()))
})
.or_else(|| derive_stable_conversation_key(request).map(|value| ("request", value)))
})
}
/// A `thread_spawn` shares its parent session but is a separate child execution.
/// Its child thread must therefore own the affinity and WebSocket pool identity.
fn thread_spawn_conversation_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
if request.subagent_kind().as_deref() != Some(THREAD_SPAWN_SUBAGENT_KIND) {
return None;
}
request
.client_session_id
.client_thread_id
.as_deref()
.map(|value| ("session", value.to_owned()))
.or_else(|| {
request
.client_conversation_id
.as_deref()
.map(|value| ("conversation", value.to_owned()))
.map(str::trim)
.filter(|thread_id| !thread_id.is_empty())
.map(|thread_id| {
(
"subagent-thread",
format!("{THREAD_SPAWN_CONVERSATION_PREFIX}{thread_id}"),
)
})
.or_else(|| {
request
.client_thread_id
.as_deref()
.map(|value| ("thread", value.to_owned()))
})
.or_else(|| {
request
.prompt_cache_key()
.map(|value| ("prompt-cache", value.to_owned()))
})
.or_else(|| derive_stable_conversation_key(request).map(|value| ("request", value)))
}
const LEADING_SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
+26 -2
View File
@@ -82,6 +82,22 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
panic!("Desktop release worker must be scheduled");
};
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
let cli_release_worker = contributions
.iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration)
if registration.id.owner() == "openai-cli-release" =>
{
Some(registration)
}
WorkerContribution::Registration(_) | WorkerContribution::Disabled { .. } => None,
})
.expect("CLI release worker");
assert_eq!(cli_release_worker.id.kind(), WorkerKind::QuotaCatalogHealth);
let WorkerRunnable::Scheduled { schedule, .. } = &cli_release_worker.runnable else {
panic!("CLI release worker must be scheduled");
};
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
assert!(contributions.iter().any(|contribution| {
matches!(
contribution,
@@ -101,13 +117,21 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
.expect("OpenAI bundle");
let admin = bundle.admin_provider();
let profile = admin.dashboard_wire_profile().expect("wire profile");
assert_eq!(profile.version, "0.102.0");
assert_eq!(profile.build, None);
assert_eq!(profile.target.os_type, "macOS");
assert_eq!(profile.target.os_version, "15.5.0");
assert_eq!(
profile.user_agent,
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
);
assert_eq!(
profile
.attributes
.iter()
.find(|attribute| attribute.label == "Codex Core")
.find(|attribute| attribute.label == "客户端标识")
.map(|attribute| attribute.value.as_str()),
Some("0.102.0")
Some("Codex Desktop; 1.2026.190")
);
assert_eq!(
profile.release.as_ref().map(|release| release.status),
@@ -13,7 +13,7 @@ fn openai_config_builds_the_audited_wire_profile() {
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.102.0 (macOS 15.5.0; arm64) xterm-256color"
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
);
assert_eq!(profile.desktop_build, "19012345678");
}
@@ -124,6 +124,10 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
"https://auth.openai.com/oauth/token",
)
);
assert_eq!(
config.wire_profile_state().snapshot().user_agent(),
"Codex Desktop/0.146.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.727.51351)"
);
}
fn valid_config() -> OpenAiConfig {
@@ -424,14 +424,71 @@ async fn passive_rate_limit_headers_replace_stale_secondary_window() {
.await
.expect("read quota")
.expect("quota snapshot");
let raw = store
.quota_json("acct_passive_quota_snapshot")
.expect("stored passive quota");
let monthly = snapshot
.windows()
.iter()
.filter(|window| window.kind() == CodexQuotaWindowKind::Monthly)
.collect::<Vec<_>>();
assert_eq!(snapshot.windows().len(), 1);
assert_eq!(monthly.len(), 1);
assert_eq!(monthly[0].used_percent(), Some(92.0));
assert!(
raw.pointer("/rate_limit/secondary_window").is_none(),
"a normal response without a secondary window must delete stale raw state"
);
}
#[tokio::test]
async fn manual_quota_refresh_drops_empty_secondary_window() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_manual_secondary_placeholder";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("created account");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header(
"authorization",
format!("Bearer token-{account_id}"),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": false,
"limit_reached": true,
"primary_window": {
"used_percent": 100,
"reset_at": 1_900_000_000,
"limit_window_seconds": 2_592_000
},
"secondary_window": {
"used_percent": 0,
"limit_reached": false
}
}
})))
.expect(1)
.mount(&server)
.await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
let snapshot = service
.refresh_account(account.id())
.await
.expect("manual quota refresh");
assert_eq!(snapshot.windows().len(), 1);
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
}
#[tokio::test]
@@ -719,6 +776,127 @@ async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_li
assert_eq!(account.availability(), AccountAvailability::QuotaExhausted);
}
#[tokio::test]
async fn periodic_quota_refresh_drops_empty_secondary_window() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_periodic_secondary_placeholder";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("created account");
store
.apply_state_change(AccountStateChange {
message: None,
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::QuotaExhausted,
observed_at: SystemTime::now(),
})
.await
.expect("mark account exhausted");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 99,
"reset_at": 1_900_000_000,
"limit_window_seconds": 2_592_000
},
"secondary_window": {}
}
})))
.expect(1)
.mount(&server)
.await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
let summary = service.synchronize().await.expect("periodic quota refresh");
let snapshot = service
.read_account(account.id())
.await
.expect("read quota")
.expect("quota snapshot");
assert_eq!(summary.exhausted, 1);
assert_eq!(snapshot.windows().len(), 1);
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
}
#[tokio::test]
async fn periodic_quota_refresh_keeps_nonempty_secondary_window_during_confirmed_exhaustion() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_periodic_real_secondary";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("created account");
store
.apply_state_change(AccountStateChange {
message: None,
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::QuotaExhausted,
observed_at: SystemTime::now(),
})
.await
.expect("mark account exhausted");
let secondary_reset_at = 1_900_604_800;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 99,
"reset_at": 1_900_000_000,
"limit_window_seconds": 2_592_000
},
"secondary_window": {
"used_percent": 0,
"reset_at": secondary_reset_at,
"limit_window_seconds": 604_800
}
}
})))
.expect(1)
.mount(&server)
.await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
let summary = service.synchronize().await.expect("periodic quota refresh");
let snapshot = service
.read_account(account.id())
.await
.expect("read quota")
.expect("quota snapshot");
assert_eq!(summary.exhausted, 1);
assert_eq!(snapshot.windows().len(), 2);
assert!(snapshot.windows().iter().any(|window| {
window.window_seconds() == Some(604_800)
&& window.used_percent() == Some(100.0)
&& window
.reset_at()
.is_some_and(|reset_at| reset_at.timestamp() == secondary_reset_at)
}));
}
#[tokio::test]
async fn periodic_quota_synchronization_respects_retry_after_on_service_unavailable() {
let store = Arc::new(MemoryAccountStore::default());
@@ -214,6 +214,27 @@ fn generate_operation() -> Operation {
))
}
fn generate_with_session_context(
session_id: &str,
thread_id: Option<&str>,
turn_metadata: Option<&str>,
) -> GenerateRequest {
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("hello")),
("session_id".to_owned(), json!(session_id)),
]);
if let Some(thread_id) = thread_id {
body.insert("thread_id".to_owned(), json!(thread_id));
}
if let Some(turn_metadata) = turn_metadata {
body.insert("turnMetadata".to_owned(), json!(turn_metadata));
}
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body).expect("OpenAI payload"),
)
}
fn http_generate_operation() -> Operation {
let payload = ProtocolPayload::json_object(
"openai",
@@ -1173,6 +1194,58 @@ async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for
assert_eq!(keys[0], keys[1]);
}
#[tokio::test]
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_thread_spawn_affinity").await;
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity(&store, Arc::clone(&affinity));
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
for (request_id, thread_id, turn_metadata) in [
(
"req_thread_spawn_first",
Some("child-one"),
Some(thread_spawn),
),
(
"req_thread_spawn_second",
Some("child-two"),
Some(thread_spawn),
),
(
"req_thread_spawn_repeat",
Some("child-one"),
Some(thread_spawn),
),
("req_thread_spawn_fallback", None, Some(thread_spawn)),
("req_thread_spawn_parent", None, None),
] {
let stream = provider
.execute(
planned_request(
"openai",
Operation::Generate(generate_with_session_context(
"parent-session",
thread_id,
turn_metadata,
)),
),
context(request_id, CancellationToken::new()),
)
.await
.expect("prepare provider stream");
drop(stream);
}
let keys = affinity.lookup_keys();
assert_eq!(keys.len(), 5);
assert_ne!(keys[0], keys[1]);
assert_eq!(keys[0], keys[2]);
assert_ne!(keys[3], keys[4]);
assert_ne!(keys[0], keys[3]);
}
#[tokio::test]
async fn invalid_local_conversation_id_should_fall_back_to_an_opaque_affinity_key() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1364,7 +1437,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {"used_percent": 99, "reset_at": reset_at}
"primary_window": {"used_percent": 99, "reset_at": reset_at},
"secondary_window": {"used_percent": 0}
}
})
.as_object()
@@ -1390,7 +1464,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {"used_percent": 99, "reset_at": reset_at}
"primary_window": {"used_percent": 99, "reset_at": reset_at},
"secondary_window": {"used_percent": 0}
}
})),
)
@@ -1439,16 +1514,22 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
.next()
.expect("confirmed quota observation");
let projected_observed_at = projected.observed_at.expect("projection observed at");
let projected = projected
.quota
.map(OpaqueProviderData::into_inner)
.expect("confirmed quota projection");
let projected = Value::Object(
projected
.quota
.map(OpaqueProviderData::into_inner)
.expect("confirmed quota projection"),
);
assert_eq!(
Value::Object(projected)
projected
.pointer("/rate_limit/primary_window/used_percent")
.and_then(Value::as_u64),
Some(100)
);
assert!(
projected.pointer("/rate_limit/secondary_window").is_none(),
"request failure must not manufacture a secondary quota"
);
tokio::time::sleep(Duration::from_millis(200)).await;
let requests = server.received_requests().await.expect("received requests");
assert_eq!(
@@ -1499,6 +1580,10 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
.and_then(Value::as_i64),
Some(reset_at)
);
assert!(
quota.pointer("/rate_limit/secondary_window").is_none(),
"background quota refresh must retain the same window projection"
);
let requests = server.received_requests().await.expect("received requests");
assert_eq!(
requests
@@ -219,6 +219,10 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
.and(header("authorization", "Bearer oauth-access"))
.and(header("chatgpt-account-id", "acct_123"))
.and(header("originator", "codex_cli_rs"))
.and(header(
"user-agent",
"codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)",
))
.and(header("accept", "application/json"))
.and(header("x-codex-installation-id", "installation-123"))
.respond_with(
@@ -60,13 +60,13 @@ impl CodexCliReleaseTransport for CliReleaseTransport {
}
#[test]
fn wire_profile_should_generate_codex_core_user_agent() {
fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
let profile = CodexWireProfile {
originator: "Codex Desktop".to_owned(),
codex_version: "0.144.2".to_owned(),
desktop_version: "26.707.72221".to_owned(),
desktop_build: "72221".to_owned(),
os_type: "macOS".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
@@ -78,7 +78,7 @@ fn wire_profile_should_generate_codex_core_user_agent() {
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.144.2 (macOS 15.7.1; arm64) unknown"
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
}
@@ -137,6 +137,7 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
#[tokio::test]
async fn wire_profile_release_update_should_change_only_desktop_identity() {
let original = wire_profile();
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let service = CodexDesktopReleaseService::new(
state.clone(),
@@ -155,6 +156,11 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() {
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.verified_at, original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
updated.user_agent(),
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.720.1)"
);
let status = service.status().snapshot();
assert_eq!(status.latest, Some(release("26.720.1", "72001")));
assert!(status.checked_at.is_some());
@@ -164,6 +170,7 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() {
#[tokio::test]
async fn cli_release_update_should_change_only_codex_core_identity() {
let original = wire_profile();
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let service = CodexCliReleaseService::new(
state.clone(),
@@ -182,6 +189,11 @@ async fn cli_release_update_should_change_only_codex_core_identity() {
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.verified_at, original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
updated.user_agent(),
"Codex Desktop/0.145.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
}
#[tokio::test]
@@ -219,7 +231,7 @@ fn wire_profile() -> CodexWireProfile {
codex_version: "0.144.2".to_owned(),
desktop_version: "26.707.72221".to_owned(),
desktop_build: "72221".to_owned(),
os_type: "macOS".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
@@ -163,7 +163,12 @@ async fn fetch_should_use_wham_usage_headers_only() {
"unexpected {name} header"
);
}
assert!(headers.get("user-agent").is_some());
assert_eq!(
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)")
);
let quota_header_names = headers
.keys()
.map(|name| name.as_str())
+4 -3
View File
@@ -57,16 +57,17 @@ api:
openai:
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
wire_profile:
# Provider 每 24 小时读取官方 npm metadata 与 Desktop appcast,仅在进程内更新版本字段。
# codex_version 是 npm CLI 的启动基线,并用于模型目录与请求 UA;Provider 每 24 小时更新它。
# Desktop appcast 同期只更新 Desktop 版本与构建号。
originator: 'Codex Desktop'
codex_version: '0.146.0'
desktop_version: '26.727.51351'
desktop_build: '6119'
os_type: 'macOS'
os_type: 'Mac OS'
os_version: '15.7.1'
arch: 'arm64'
terminal: 'unknown'
verified_at: '2026-07-25T00:00:00+08:00'
verified_at: '2026-08-04T00:00:00+08:00'
xai:
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
+5 -2
View File
@@ -110,10 +110,13 @@ const labelClasses = computed(() => [
>
<span
v-if="loading"
class="inline-flex shrink-0 animate-spin origin-center will-change-transform"
class="inline-flex shrink-0 items-center justify-center leading-none"
aria-hidden="true"
>
<LoaderCircle :size="loadingIconSize[size]" />
<LoaderCircle
:size="loadingIconSize[size]"
class="block animate-spin origin-center will-change-transform transform-fill"
/>
</span>
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
@@ -26,31 +26,31 @@ const statusStyles = {
success: {
text: 'text-(--cp-success-text)',
dot: 'bg-(--cp-success)',
badge: 'border-(--cp-success-border) bg-(--cp-success-bg) text-(--cp-success-text)',
icon: 'bg-(--cp-success-bg) text-(--cp-success-text) shadow-[inset_0_0_0_1px_var(--cp-success-border)]',
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
icon: 'bg-(--cp-success-bg) text-(--cp-success-text)',
},
danger: {
text: 'text-(--cp-danger-text)',
dot: 'bg-(--cp-danger)',
badge: 'border-(--cp-danger-border) bg-(--cp-danger-bg) text-(--cp-danger-text)',
icon: 'bg-(--cp-danger-bg) text-(--cp-danger-text) shadow-[inset_0_0_0_1px_var(--cp-danger-border)]',
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
icon: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
},
warning: {
text: 'text-(--cp-warning-text)',
dot: 'bg-(--cp-warning)',
badge: 'border-(--cp-warning-border) bg-(--cp-warning-bg) text-(--cp-warning-text)',
icon: 'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
badge: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
icon: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
},
info: {
text: 'text-(--cp-info-text)',
dot: 'bg-(--cp-info)',
badge: 'border-(--cp-info-border) bg-(--cp-info-bg) text-(--cp-info-text)',
icon: 'bg-(--cp-info-bg) text-(--cp-info-text) shadow-[inset_0_0_0_1px_var(--cp-info-border)]',
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
icon: 'bg-(--cp-info-bg) text-(--cp-info-text)',
},
normal: {
text: 'text-(--cp-text-secondary)',
dot: 'bg-(--cp-text-muted)',
badge: 'border-(--cp-divider-subtle) bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
badge: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
icon: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
},
} as const
@@ -165,7 +165,7 @@ const cardIcon = computed(() => {
</template>
<section class="overflow-hidden rounded-(--cp-popover-radius)">
<header class="flex items-start gap-3 border-b border-(--cp-divider-subtle) bg-(--cp-bg-subtle) px-4 py-3">
<header class="flex items-start gap-3 bg-(--cp-bg-subtle) px-4 py-3">
<span
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius)"
@@ -184,7 +184,7 @@ const cardIcon = computed(() => {
</div>
<span
class="inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-[10px] leading-none font-heavy"
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[10px] leading-none font-heavy"
:class="statusStyle.badge"
>
{{ label }}
@@ -206,7 +206,7 @@ const cardIcon = computed(() => {
</span>
</div>
<div class="rounded-(--cp-input-radius-base) border border-(--cp-divider-subtle) bg-(--cp-bg-surface) px-3 py-2.5">
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5">
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-tertiary)">
建议操作
</p>
@@ -217,13 +217,13 @@ const cardIcon = computed(() => {
<div
v-if="errorText"
class="overflow-hidden rounded-(--cp-input-radius-base) border border-(--cp-divider-subtle) bg-(--cp-bg-subtle)"
class="overflow-hidden rounded-(--cp-input-radius-base) bg-(--cp-bg-muted)"
>
<div class="flex items-center justify-between gap-3 border-b border-(--cp-divider-subtle) px-3 py-2">
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
<span class="text-[11px] leading-none font-heavy text-(--cp-text-tertiary)">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-(--cp-text-muted)">仅供排查</span>
</div>
<BaseScrollbar max-height="124px" view-class="px-3 py-2 pr-2">
<BaseScrollbar class="bg-(--cp-bg-subtle)" max-height="124px" view-class="px-3 py-2 pr-2">
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-(--cp-text-secondary)">{{ errorText }}</pre>
</BaseScrollbar>
</div>
@@ -124,8 +124,7 @@ const clientIdentity = computed(() => {
const current = profile.value
if (!current)
return '—'
const label = current.provider === 'openai' ? 'Codex Core' : '客户端标识'
const value = current.attributes.find(attribute => attribute.label === label)?.value ?? '—'
const value = current.attributes.find(attribute => attribute.label === '客户端标识')?.value ?? '—'
return current.provider === 'xai' ? toPascalCase(value) : value
})
@@ -144,7 +143,6 @@ const runtimeEnvironment = computed(() => {
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
const primary = [target.osType, target.osVersion]
.filter(present)
.map(value => value.toLowerCase() === 'linux' ? 'Linux' : value)
.join(' ')
const details = [target.arch, target.terminal].filter(present)
return {
@@ -286,7 +284,7 @@ function providerLabel(provider: string) {
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-(--cp-text-muted)"
>
<Terminal aria-hidden="true" class="size-3.25 text-(--cp-info)" />
{{ profile.provider === 'openai' ? 'Codex Core' : 'Grok Client' }}
客户端标识
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-(--cp-text-primary)"
@@ -409,16 +409,17 @@ const tokenDonutOption = computed<EChartsOption>(() => {
尝试链路
</h3>
<span
class="rounded-full bg-(--cp-bg-muted) px-2 py-0.5 text-[11px] leading-none font-emphasis text-(--cp-text-secondary)"
class="text-[11px] leading-none font-emphasis text-(--cp-text-muted)"
title="中间尝试可能有缺口,仅展示可观测到的记录"
>
尽力观测
</span>
</div>
<BaseTable
class="mt-3 min-w-0 font-sans"
class="attempt-table mt-2.5 min-w-0 font-mono tabular-nums"
:columns="attemptColumns"
:rows="attemptRows"
:stripe="false"
compact
row-key="id"
min-width="640px"
@@ -478,3 +479,18 @@ const tokenDonutOption = computed<EChartsOption>(() => {
</template>
</BaseModal>
</template>
<style scoped>
.attempt-table :deep(thead th) {
background-color: transparent;
box-shadow: none;
}
.attempt-table :deep(tbody tr) {
background-color: transparent;
}
.attempt-table :deep(tbody td) {
background-color: var(--cp-bg-muted);
}
</style>
+1 -1
View File
@@ -1 +1 @@
version: "3.1.6"
version: "3.1.8"