feat(openai): support complete identities with official release updates (#272)

Reuse official daily release checks for complete TUI and Exec identities
without third-party catalogs. Keep settings and key overrides on a compact
preset and version-policy editor, with matching previews and loading states.
Prefill custom versions with the current user agent. Compare settings by
value, align toolbar icon backgrounds, and compact the key creation form.

Release transaction locks before rejecting stale proxy test results,
with a regression covering an immediate edit during connection cleanup.

Co-authored-by: Wei Hu <whuae@connect.ust.hk>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Wei HU
2026-09-26 01:35:43 +08:00
committed by Zyy
co-authored by Wei Hu Codex
parent 999fcc1770
commit 5782c71f73
35 changed files with 1222 additions and 224 deletions
@@ -815,6 +815,11 @@ impl ProxyStore for PgProxyRepository {
.await
.map_err(store_error)?;
if current.revision != revision {
// Drop 只排队回滚,返回冲突前需释放事务锁,避免误挡紧接着的编辑。
transaction
.rollback()
.await
.map_err(|_| store_error(unavailable()))?;
return Err(store_error(conflict(id)));
}
save_test(&mut transaction, &current, result)
@@ -517,6 +517,80 @@ async fn rejected_import_reservations_release_proxy_lock_before_returning() {
database.close().await;
}
#[tokio::test]
async fn stale_proxy_test_releases_lock_before_next_update() {
let Some(database) = TestDatabase::create("stale_proxy_test_lock").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let context = context();
let mut saved = store
.create(
NewProxy {
name: "过期检测结果".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:19090").unwrap(),
auto_location: true,
location: None,
test: None,
},
&context,
)
.await
.unwrap()
.record;
let stale_revision = saved.revision;
saved = store
.record_test(&saved.id, saved.revision, success(), &context)
.await
.unwrap()
.record;
// 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放。
let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
let rejected_pool = database
.pool
.options()
.clone()
.max_connections(1)
.after_release({
let release_gate = release_gate.clone();
move |_, _| {
let release_gate = release_gate.clone();
Box::pin(async move {
let _permit = release_gate.acquire().await.unwrap();
Ok(true)
})
}
})
.connect_with((*database.pool.connect_options()).clone())
.await
.unwrap();
let stale_writer = PgProxyRepository::new(rejected_pool.clone());
let error = stale_writer
.record_test(&saved.id, stale_revision, success(), &context)
.await
.unwrap_err();
let update = store
.update(
UpdateProxy {
id: saved.id.clone(),
revision: saved.revision,
name: saved.name.clone(),
proxy: None,
auto_location: None,
location: None,
test: None,
},
&context,
)
.await;
release_gate.add_permits(1);
rejected_pool.close().await;
database.close().await;
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
assert!(update.is_ok(), "过期检测结果不应阻挡后续编辑: {update:?}");
}
#[tokio::test]
async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() {
use gateway_store::postgres::{
+54 -43
View File
@@ -287,47 +287,48 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
configuration: &OpaqueProviderData,
) -> Option<DashboardWireProfile> {
use crate::transport::profile::selection::{
ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
};
let selection = ClientProfileSelection::parse(configuration).ok()?;
use crate::transport::profile::identity::RequestProfileSelection;
use crate::transport::profile::selection::{ClientKind, ClientPlatform, VersionMode};
let selection = RequestProfileSelection::parse(configuration).ok()?;
let profile = selection.resolve(&self.profile).ok()?;
let custom = selection.version_mode == VersionMode::Fixed;
let (checked_at, error) = self.profile.client_release_status(
selection.client,
selection.platform,
selection.architecture(),
);
let release = if custom {
None
} else if selection.client == ClientKind::Desktop
&& selection.platform == ClientPlatform::Macos
{
Some(dashboard_desktop_release(
&profile,
self.desktop_release.snapshot(),
))
} else {
Some(DashboardDesktopRelease {
status: if error.is_some() {
DesktopReleaseStatus::Failed
} else if checked_at.is_some() {
DesktopReleaseStatus::Current
} else {
DesktopReleaseStatus::Unchecked
},
checked_at,
latest_version: Some(profile.codex_version.clone()),
latest_build: None,
published_at: None,
minimum_system_version: None,
hardware_requirements: None,
download_url: None,
download_size: None,
signature_present: None,
error,
})
};
let custom = selection.version_mode() == VersionMode::Fixed;
let release = selection.preset().and_then(|selection| {
let (checked_at, error) = self.profile.client_release_status(
selection.client,
selection.platform,
selection.architecture(),
);
if custom {
None
} else if selection.client == ClientKind::Desktop
&& selection.platform == ClientPlatform::Macos
{
Some(dashboard_desktop_release(
&profile,
self.desktop_release.snapshot(),
))
} else {
Some(DashboardDesktopRelease {
status: if error.is_some() {
DesktopReleaseStatus::Failed
} else if checked_at.is_some() {
DesktopReleaseStatus::Current
} else {
DesktopReleaseStatus::Unchecked
},
checked_at,
latest_version: Some(profile.codex_version.clone()),
latest_build: None,
published_at: None,
minimum_system_version: None,
hardware_requirements: None,
download_url: None,
download_size: None,
signature_present: None,
error,
})
}
});
Some(DashboardWireProfile {
provider: self.provider_kind.as_str().to_owned(),
product: profile.originator.clone(),
@@ -343,7 +344,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
attributes: vec![
DashboardWireAttribute {
label: "客户端标识".to_owned(),
value: if selection.client == ClientKind::Desktop {
value: if profile.client_kind == ClientKind::Desktop {
format!("{}; {}", profile.originator, profile.desktop_version)
} else {
profile.originator
@@ -352,14 +353,15 @@ impl ProviderAdmin for OpenAiAdminProvider {
DashboardWireAttribute {
label: "版本策略".to_owned(),
value: if custom {
"固定自定义"
"固定身份"
} else {
"自动最新"
}
.to_owned(),
},
],
verified_at: (!custom).then_some(profile.verified_at),
verified_at: (!custom && profile.verified_at != chrono::DateTime::UNIX_EPOCH)
.then_some(profile.verified_at),
release,
})
}
@@ -1934,6 +1936,15 @@ fn map_client_profile_error(
use crate::transport::profile::selection::ClientProfileError;
let message = match error {
ClientProfileError::Invalid => "客户端身份字段或版本组合不合法",
ClientProfileError::InvalidUserAgent => {
"User-Agent 必须是 1 至 4096 字节的单行 ASCII 文本,且首尾不能含空白"
}
ClientProfileError::CompanionHeadersRequired => {
"无法识别 User-Agent,请补充 originator 和有效的 Core version"
}
ClientProfileError::CompanionHeadersConflict => {
"originator 或 Core version 与 User-Agent 不一致"
}
ClientProfileError::ReleaseUnavailable => {
"此客户端、平台与架构尚无已核验发布版本,请选择固定版本或稍后重试"
}
@@ -277,7 +277,7 @@ impl CodexCredentialCatalogService {
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let request_profile = match scope.request_profile(&provider) {
Some(configuration) => {
crate::transport::profile::selection::ClientProfileSelection::parse(configuration)
crate::transport::profile::identity::RequestProfileSelection::parse(configuration)
.and_then(|selection| selection.resolve(&self.profile))
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?
}
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn initialize(
.initialize_request_profile(&provider_kind, initial_profile)
.await
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
transport::profile::selection::ClientProfileSelection::parse(&configured_profile)
transport::profile::identity::RequestProfileSelection::parse(&configured_profile)
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
let credential_state = ports.credential_state();
let profile =
@@ -297,7 +297,7 @@ impl Provider for CodexProvider {
configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderError> {
let selection =
crate::transport::profile::selection::ClientProfileSelection::parse(configuration)
crate::transport::profile::identity::RequestProfileSelection::parse(configuration)
.map_err(|_| {
provider_error(
ProviderErrorKind::InvalidRequest,
@@ -651,14 +651,19 @@ fn websocket_connection_profile(
headers: &HeaderMap,
middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader],
) -> String {
let mut profile = ["originator", "user-agent", X_OPENAI_MEMGEN_REQUEST_HEADER]
.map(|name| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
})
.join("\0");
let mut profile = [
"originator",
"user-agent",
"version",
X_OPENAI_MEMGEN_REQUEST_HEADER,
]
.map(|name| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
})
.join("\0");
if !middleware_headers.is_empty() {
use sha2::{Digest, Sha256};
@@ -0,0 +1,255 @@
//! 预设与完整自定义 UA 的统一解析边界,身份配套头由 Provider 所有。
use chrono::DateTime;
use gateway_core::account::OpaqueProviderData;
use serde::Deserialize;
use serde_json::{Value, json};
use super::selection::{
ClientKind, ClientProfileError, ClientProfileSelection, VersionMode, object,
};
use super::{CodexWireProfile, CodexWireProfileState};
/// 已解析的配置;没有 mode 的预设配置复用官方版本与平台合同。
#[derive(Debug, Clone)]
pub struct RequestProfileSelection(ParsedSelection);
#[derive(Debug, Clone)]
enum ParsedSelection {
Preset(ClientProfileSelection),
Custom(ExactIdentity),
}
#[derive(Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
enum SelectionDocument {
Custom {
#[serde(rename = "userAgent")]
user_agent: String,
originator: Option<String>,
#[serde(rename = "codexVersion")]
codex_version: Option<String>,
},
}
impl RequestProfileSelection {
pub fn preset(&self) -> Option<&ClientProfileSelection> {
match &self.0 {
ParsedSelection::Preset(selection) => Some(selection),
_ => None,
}
}
pub fn version_mode(&self) -> VersionMode {
match &self.0 {
ParsedSelection::Preset(selection) => selection.version_mode,
ParsedSelection::Custom(_) => VersionMode::Fixed,
}
}
pub fn parse(document: &OpaqueProviderData) -> Result<Self, ClientProfileError> {
let fields = document.expose_to_provider();
if !fields.contains_key("mode") {
return ClientProfileSelection::parse(document)
.map(ParsedSelection::Preset)
.map(Self);
}
let document = serde_json::from_value(Value::Object(fields.clone()))
.map_err(|_| ClientProfileError::Invalid)?;
let selection = match document {
SelectionDocument::Custom {
user_agent,
originator,
codex_version,
} => ParsedSelection::Custom(ExactIdentity::parse(
&user_agent,
originator.as_deref(),
codex_version.as_deref(),
)?),
};
Ok(Self(selection))
}
pub fn resolve(
&self,
state: &CodexWireProfileState,
) -> Result<CodexWireProfile, ClientProfileError> {
match &self.0 {
ParsedSelection::Preset(selection) => selection.resolve(state),
ParsedSelection::Custom(identity) => Ok(identity.profile(state)),
}
}
pub(super) fn preview(
&self,
state: &CodexWireProfileState,
) -> Result<OpaqueProviderData, ClientProfileError> {
match &self.0 {
ParsedSelection::Preset(selection) => state.preview_preset_selection(selection),
ParsedSelection::Custom(identity) => identity.preview(state),
}
}
}
#[derive(Debug, Clone)]
struct ExactIdentity {
user_agent: String,
originator: String,
codex_version: String,
recognized: bool,
environment: Option<UaEnvironment>,
desktop_version: Option<String>,
}
impl ExactIdentity {
fn parse(
user_agent: &str,
originator: Option<&str>,
codex_version: Option<&str>,
) -> Result<Self, ClientProfileError> {
if !safe_header(user_agent, 4096) {
return Err(ClientProfileError::InvalidUserAgent);
}
let known = ["Codex Desktop", "codex-tui", "codex_exec", "codex_cli_rs"]
.into_iter()
.find_map(|name| {
user_agent
.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('/'))
.map(|rest| (name, rest))
});
let (originator, codex_version, recognized) = if let Some((name, rest)) = known {
let version = rest.split_once(' ').map_or(rest, |(version, _)| version);
parse_core_version(version)?;
if originator.is_some_and(|value| value != name)
|| codex_version.is_some_and(|value| value != version)
{
return Err(ClientProfileError::CompanionHeadersConflict);
}
(name.to_owned(), version.to_owned(), true)
} else {
let originator = originator
.filter(|value| safe_header(value, 128))
.ok_or(ClientProfileError::CompanionHeadersRequired)?;
let version = codex_version.ok_or(ClientProfileError::CompanionHeadersRequired)?;
parse_core_version(version)?;
(originator.to_owned(), version.to_owned(), false)
};
let environment = known.and_then(|(_, rest)| UaEnvironment::parse(rest));
let desktop_version = (originator == "Codex Desktop")
.then(|| {
user_agent
.rsplit_once(" (Codex Desktop; ")
.and_then(|(_, version)| version.strip_suffix(')'))
.filter(|version| super::numeric_dotted_version(version))
.map(ToOwned::to_owned)
})
.flatten();
Ok(Self {
user_agent: user_agent.to_owned(),
originator,
codex_version,
recognized,
environment,
desktop_version,
})
}
fn profile(&self, state: &CodexWireProfileState) -> CodexWireProfile {
let environment = self.environment.as_ref();
CodexWireProfile {
client_kind: if self.originator == "Codex Desktop" {
ClientKind::Desktop
} else {
ClientKind::Cli
},
originator: self.originator.clone(),
codex_version: self.codex_version.clone(),
desktop_version: self.desktop_version.clone().unwrap_or_default(),
desktop_build: String::new(),
os_type: environment
.map(|value| value.os_type.clone())
.unwrap_or_default(),
os_version: environment
.map(|value| value.os_version.clone())
.unwrap_or_default(),
arch: environment
.map(|value| value.arch.clone())
.unwrap_or_default(),
terminal: environment
.map(|value| value.terminal.clone())
.unwrap_or_default(),
exact_user_agent: Some(self.user_agent.clone()),
residency: state.snapshot().residency,
verified_at: DateTime::UNIX_EPOCH,
}
}
fn preview(
&self,
state: &CodexWireProfileState,
) -> Result<OpaqueProviderData, ClientProfileError> {
let profile = self.profile(state);
object(&json!({
"configuration": {
"mode": "custom", "userAgent": self.user_agent,
"originator": self.originator, "codexVersion": self.codex_version,
},
"originator": profile.originator,
"osType": profile.os_type,
"osVersion": profile.os_version,
"arch": profile.arch,
"terminal": profile.terminal,
"codexVersion": profile.codex_version,
"desktopVersion": self.desktop_version,
"desktopBuild": null,
"userAgent": profile.user_agent(),
"recognized": self.recognized,
"versionSource": "custom",
"verifiedAt": null,
"checkedAt": null,
"error": null,
}))
}
}
#[derive(Debug, Clone)]
struct UaEnvironment {
os_type: String,
os_version: String,
arch: String,
terminal: String,
}
impl UaEnvironment {
fn parse(rest: &str) -> Option<Self> {
let (_, rest) = rest.split_once(" (")?;
let (environment, tail) = rest.split_once(") ")?;
let (os, arch) = environment.split_once("; ")?;
let (os_type, os_version) = os.rsplit_once(' ')?;
let terminal = tail.split_once(" (").map_or(tail, |(terminal, _)| terminal);
if os_type.is_empty() || os_version.is_empty() || arch.is_empty() || terminal.is_empty() {
return None;
}
Some(Self {
os_type: os_type.to_owned(),
os_version: os_version.to_owned(),
arch: arch.to_owned(),
terminal: terminal.to_owned(),
})
}
}
fn safe_header(value: &str, max_bytes: usize) -> bool {
!value.is_empty()
&& value.len() <= max_bytes
&& value.trim() == value
&& value.bytes().all(|byte| (32..=126).contains(&byte))
}
fn parse_core_version(value: &str) -> Result<semver::Version, ClientProfileError> {
if value.len() > 64 {
return Err(ClientProfileError::Invalid);
}
semver::Version::parse(value).map_err(|_| ClientProfileError::Invalid)
}
@@ -22,6 +22,7 @@ use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version
pub mod cli_release;
pub mod desktop_artifact;
pub mod identity;
pub mod platform_release;
pub mod selection;
@@ -48,16 +49,16 @@ pub enum CodexResidency {
/// 保留已有配置类型入口;位置规则由出口领域统一维护。
pub use gateway_core::account::RequestLocation as CodexRequestLocation;
/// Codex Desktop 上游请求身份。
/// Codex 上游请求身份快照。
///
/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用
/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。
/// 预设按官方配套版本生成 UA;自定义身份保留完整 UA 及其配套请求头。
/// 官方制品核验只更新独立的基线与预设资料,不改写已冻结的请求身份。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexWireProfile {
pub client_kind: ClientKind,
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// Desktop ZIP 内嵌 Core 版本;用于模型请求的 version、client_version 与 UA。
/// Core 版本,用于模型请求的 version 和 client_version。
pub codex_version: String,
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
pub desktop_version: String,
@@ -71,6 +72,9 @@ pub struct CodexWireProfile {
pub arch: String,
/// Codex Core UA 中的终端标记。
pub terminal: String,
/// 入口专属或自定义的完整值;存在时不得用默认预设重新拼接。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact_user_agent: Option<String>,
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
pub residency: Option<CodexResidency>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
@@ -89,6 +93,7 @@ impl Default for CodexWireProfile {
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
@@ -97,8 +102,11 @@ impl Default for CodexWireProfile {
}
impl CodexWireProfile {
/// 按 bundled Core app-server 的官方格式生成最终 User-Agent。
/// 原样返回已解析的完整 UA;默认预设按 bundled Core app-server 格式生成。
pub fn user_agent(&self) -> String {
if let Some(user_agent) = &self.exact_user_agent {
return user_agent.clone();
}
if self.client_kind == ClientKind::Cli {
return format!(
"{}/{} ({} {}; {}) {}",
@@ -14,6 +14,23 @@ pub enum ClientKind {
Cli,
}
/// CLI 共用官方发布版本,但不同入口提供各自的客户端标识和 UA 后缀。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliEntry {
Tui,
Exec,
}
impl CliEntry {
pub const fn originator(self) -> &'static str {
match self {
Self::Tui => "codex-tui",
Self::Exec => "codex_exec",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientPlatform {
@@ -61,7 +78,9 @@ pub struct ClientProfileSelection {
pub client: ClientKind,
pub platform: ClientPlatform,
pub version_mode: VersionMode,
pub cli_entry: Option<CliEntry>,
pub originator: Option<String>,
pub os_type: Option<String>,
pub os_version: Option<String>,
pub arch: Option<String>,
pub terminal: Option<String>,
@@ -76,7 +95,9 @@ impl Default for ClientProfileSelection {
client: ClientKind::Desktop,
platform: ClientPlatform::Macos,
version_mode: VersionMode::Latest,
cli_entry: None,
originator: None,
os_type: None,
os_version: None,
arch: None,
terminal: None,
@@ -104,9 +125,20 @@ impl ClientProfileSelection {
self.arch.as_deref().unwrap_or(self.platform.default_arch())
}
fn default_originator(&self) -> &'static str {
match self.client {
ClientKind::Desktop => "Codex Desktop",
ClientKind::Cli => self.cli_entry.map_or("codex_cli_rs", CliEntry::originator),
}
}
pub(super) fn validate(&self) -> Result<(), ClientProfileError> {
if self.client == ClientKind::Desktop && self.cli_entry.is_some() {
return Err(ClientProfileError::Invalid);
}
for value in [
self.originator.as_deref(),
self.os_type.as_deref(),
self.os_version.as_deref(),
self.arch.as_deref(),
self.terminal.as_deref(),
@@ -185,19 +217,19 @@ impl ClientProfileSelection {
verified_at: None,
},
};
Ok(CodexWireProfile {
let mut profile = CodexWireProfile {
client_kind: self.client,
originator: self
.originator
.clone()
.unwrap_or_else(|| match self.client {
ClientKind::Desktop => "Codex Desktop".to_owned(),
ClientKind::Cli => "codex_cli_rs".to_owned(),
}),
.unwrap_or_else(|| self.default_originator().to_owned()),
codex_version: release.codex_version,
desktop_version: release.desktop_version.unwrap_or_default(),
desktop_build: release.desktop_build.unwrap_or_default(),
os_type: self.platform.os_type().to_owned(),
os_type: self
.os_type
.clone()
.unwrap_or_else(|| self.platform.os_type().to_owned()),
os_version: self
.os_version
.clone()
@@ -207,9 +239,21 @@ impl ClientProfileSelection {
.terminal
.clone()
.unwrap_or_else(|| "unknown".to_owned()),
exact_user_agent: None,
residency: state.snapshot().residency,
verified_at: release.verified_at.unwrap_or(DateTime::UNIX_EPOCH),
})
};
if let Some(entry) = self.cli_entry {
// 入口后缀使用同一次解析得到的 Core 版本,避免每日更新后头部与后缀混用。
// originator 可单独覆盖;入口名与官方 clientInfo.name 的语义保持一致。
profile.exact_user_agent = Some(format!(
"{} ({}; {})",
profile.user_agent(),
entry.originator(),
profile.codex_version,
));
}
Ok(profile)
}
}
@@ -228,7 +272,13 @@ impl CodexWireProfileState {
&self,
configuration: &OpaqueProviderData,
) -> Result<OpaqueProviderData, ClientProfileError> {
let selection = ClientProfileSelection::parse(configuration)?;
super::identity::RequestProfileSelection::parse(configuration)?.preview(self)
}
pub(super) fn preview_preset_selection(
&self,
selection: &ClientProfileSelection,
) -> Result<OpaqueProviderData, ClientProfileError> {
let profile = selection.resolve(self)?;
let status = self.client_release_status(
selection.client,
@@ -271,7 +321,7 @@ impl CodexWireProfileState {
"configuration": configuration,
"automaticAvailable": available,
"reason": (!available).then_some("暂不支持自动更新"),
"defaults": { "originator": if client == ClientKind::Desktop { "Codex Desktop" } else { "codex_cli_rs" }, "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" },
"defaults": { "originator": configuration.default_originator(), "osType": platform.os_type(), "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" },
}));
}
}
@@ -290,6 +340,12 @@ pub(crate) fn object(value: &impl Serialize) -> Result<OpaqueProviderData, Clien
pub enum ClientProfileError {
#[error("客户端身份字段或版本组合不合法")]
Invalid,
#[error("User-Agent 必须是 1 至 4096 字节的单行 ASCII 文本,且首尾不能含空白")]
InvalidUserAgent,
#[error("无法识别 User-Agent,请补充 originator 和有效的 Core version")]
CompanionHeadersRequired,
#[error("originator 或 Core version 与 User-Agent 不一致")]
CompanionHeadersConflict,
#[error("此客户端、平台与架构尚无已核验发布版本,请选择固定版本或稍后重试")]
ReleaseUnavailable,
}
@@ -87,6 +87,35 @@ async fn account_capabilities_distinguish_oauth_from_api_key_and_unknown_credent
}
}
#[tokio::test]
async fn custom_identity_preview_and_dashboard_share_the_exact_request_profile() {
let config = valid_config();
let bundle = provider_openai::initialize(config.config.clone(), provider_ports())
.await
.unwrap();
let provider = bundle.admin_provider();
let user_agent =
"codex_exec/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex_exec; 0.156.1)";
let configuration = OpaqueProviderData::new(
json!({"mode":"custom", "userAgent":user_agent})
.as_object()
.unwrap()
.clone(),
);
let preview = provider.preview_client_profile(&configuration).unwrap();
let dashboard = provider.configured_wire_profile(&configuration).unwrap();
assert_eq!(preview.expose_to_provider()["userAgent"], user_agent);
assert_eq!(dashboard.user_agent, user_agent);
assert_eq!(dashboard.product, "codex_exec");
assert_eq!(dashboard.version, "0.156.1");
assert!(dashboard.verified_at.is_none());
assert!(dashboard.release.is_none());
assert_ne!(
provider.dashboard_wire_profile().unwrap().user_agent,
user_agent
);
}
#[tokio::test]
async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() {
let config = valid_config();
@@ -65,6 +65,7 @@ fn wire_profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "catalog-contract".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
@@ -162,6 +162,7 @@ fn selector_with_runtime(
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "selector-contract".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: chrono::Utc::now(),
});
@@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
@@ -40,6 +40,7 @@ fn wire_profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "quota-contract".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
@@ -984,6 +984,7 @@ fn wire_profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "provider-contract".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc::now(),
})
@@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
@@ -712,6 +712,96 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
assert_ne!(first_user_agent, second_user_agent);
}
#[tokio::test]
async fn custom_core_version_change_reconnects_new_chains_and_keeps_exact_continuations() {
use gateway_core::account::OpaqueProviderData;
use provider_openai::transport::profile::identity::RequestProfileSelection;
const USER_AGENT: &str = "Custom Agent/test";
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut first = accept_codex_test_websocket_with(stream, |request, _response| {
assert_eq!(request.headers()["user-agent"], USER_AGENT);
assert_eq!(request.headers()["originator"], "custom-agent");
assert_eq!(request.headers()["version"], "1.2.3");
})
.await;
for response_id in ["resp_custom_first", "resp_custom_continued"] {
timeout(Duration::from_secs(2), first.next())
.await
.expect("exact continuation uses the original connection")
.unwrap()
.unwrap();
first
.send(Message::Text(
completed_websocket_response(response_id, 1, 1).into(),
))
.await
.unwrap();
}
let (stream, _) = timeout(Duration::from_secs(2), listener.accept())
.await
.expect("a Core version change requires a new connection for a new chain")
.unwrap();
let mut second = accept_codex_test_websocket_with(stream, |request, _response| {
assert_eq!(request.headers()["user-agent"], USER_AGENT);
assert_eq!(request.headers()["originator"], "custom-agent");
assert_eq!(request.headers()["version"], "1.2.4");
})
.await;
second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_custom_second", 1, 1).into(),
))
.await
.unwrap();
});
let state = test_wire_profile();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{address}"),
state.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let with_version = |version| {
let configuration = json!({
"mode": "custom",
"userAgent": USER_AGENT,
"originator": "custom-agent",
"codexVersion": version,
});
let profile = RequestProfileSelection::parse(&OpaqueProviderData::new(
configuration.as_object().unwrap().clone(),
))
.unwrap()
.resolve(&state)
.unwrap();
client.clone().with_request_profile(profile)
};
let mut request = codex_request("gpt-test", "", Vec::new());
request.use_websocket = true;
request.local_conversation_id = Some("custom-profile-rotation".to_owned());
let context = request_context("req_custom_profile", Some("acct-custom-profile"));
with_version("1.2.3")
.create_response(&request, context)
.await
.unwrap();
let updated = with_version("1.2.4");
let mut continuation = request.clone();
continuation.set_previous_response_id(Some("resp_custom_first".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
updated
.create_response(&continuation, context)
.await
.unwrap();
updated.create_response(&request, context).await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn concurrent_request_profiles_emit_independent_http_identities() {
use provider_openai::transport::profile::selection::{
@@ -786,3 +876,79 @@ async fn concurrent_request_profiles_emit_independent_http_identities() {
);
}
}
#[tokio::test]
async fn tui_and_exec_send_the_same_complete_identity_over_http_and_websocket() {
use provider_openai::transport::profile::selection::{
CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
};
for (entry, name) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let expected =
format!("{name}/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown ({name}; 0.157.0)");
let server = tokio::spawn(async move {
let (mut http, _) = listener.accept().await.unwrap();
let headers = read_http_request(&mut http).await;
assert_eq!(
read_header_value(&headers, "user-agent"),
Some(expected.as_str())
);
assert_eq!(read_header_value(&headers, "originator"), Some(name));
assert_eq!(read_header_value(&headers, "version"), Some("0.157.0"));
write_completed_sse_response(&mut http).await;
let (stream, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket_with(stream, |request, _| {
assert_eq!(request.headers()["user-agent"], expected);
assert_eq!(request.headers()["originator"], name);
assert_eq!(request.headers()["version"], "0.157.0");
})
.await;
ws.next().await.unwrap().unwrap();
ws.send(Message::Text(
completed_websocket_response("resp_cli_entry", 1, 1).into(),
))
.await
.unwrap();
});
let state =
provider_openai::transport::profile::CodexWireProfileState::new(Default::default());
let profile = ClientProfileSelection {
client: ClientKind::Cli,
platform: ClientPlatform::Linux,
cli_entry: Some(entry),
os_type: Some("Alpine Linux".into()),
os_version: Some("3.24.1".into()),
version_mode: VersionMode::Fixed,
codex_version: Some("0.157.0".into()),
..Default::default()
}
.resolve(&state)
.unwrap();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{address}"),
state,
)
.with_request_profile(profile);
let mut request = codex_request("gpt-test", "", Vec::new());
request.force_http_sse = true;
client
.create_response(
&request,
request_context("req_cli_http", Some("acct-fixture")),
)
.await
.unwrap();
request.force_http_sse = false;
request.use_websocket = true;
client
.create_response(
&request,
request_context("req_cli_ws", Some("acct-fixture")),
)
.await
.unwrap();
server.await.unwrap();
}
}
@@ -133,6 +133,7 @@ fn test_wire_profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "transport-test".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc::now(),
})
@@ -31,3 +31,96 @@ fn cli_latest_requires_matching_stable_platform_dependencies() {
assert!(parse_cli_release(&serde_json::to_vec(&manifest).unwrap()).is_err());
}
}
#[tokio::test]
async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_identities() {
use std::sync::Arc;
use std::time::Duration;
use gateway_core::account::OpaqueProviderData;
use gateway_core::provider_ports::{ProviderArtifactProfile, ProviderArtifactProfileCachePort};
use gateway_core::routing::ProviderKind;
use provider_openai::transport::headers::build_codex_model_headers;
use provider_openai::transport::profile::CodexWireProfileState;
use provider_openai::transport::profile::cli_release::CliReleaseService;
use provider_openai::transport::profile::identity::RequestProfileSelection;
use provider_openai::transport::profile::selection::{
CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
};
let state = CodexWireProfileState::new(super::wire_profile());
let cache = Arc::new(super::ArtifactProfiles::default());
let provider = ProviderKind::new("openai").unwrap();
let service = CliReleaseService::new(provider.clone(), state.clone(), cache.clone()).unwrap();
let automatic = ClientProfileSelection {
client: ClientKind::Cli,
platform: ClientPlatform::Linux,
cli_entry: Some(CliEntry::Tui),
os_type: Some("Alpine Linux".into()),
os_version: Some("3.24.1".into()),
terminal: Some("xterm-256color".into()),
..Default::default()
};
let frozen = automatic.resolve(&state).unwrap();
let fixed = ClientProfileSelection {
version_mode: VersionMode::Fixed,
codex_version: Some(frozen.codex_version.clone()),
..automatic.clone()
};
let custom = RequestProfileSelection::parse(&OpaqueProviderData::new(
json!({"mode":"custom", "userAgent":frozen.user_agent()})
.as_object()
.unwrap()
.clone(),
))
.unwrap();
// 复用官方版本服务的恢复入口,模拟每日检查已核验并写入的下一份发布资料。
cache
.replace_if_newer(
ProviderArtifactProfile::new(
provider.clone(),
"cli-linux-x64".into(),
157_001,
chrono::Utc::now().into(),
OpaqueProviderData::new(json!({"version":"0.157.0"}).as_object().unwrap().clone()),
),
Duration::from_secs(86400),
)
.await
.unwrap();
service.restore().await;
for (entry, originator) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] {
let selection = ClientProfileSelection {
cli_entry: Some(entry),
..automatic.clone()
};
let current = selection.resolve(&state).unwrap();
let headers = build_codex_model_headers(&current, "Bearer fixture", None).unwrap();
let expected = format!(
"{originator}/0.157.0 (Alpine Linux 3.24.1; x86_64) xterm-256color ({originator}; 0.157.0)"
);
assert_eq!(headers["user-agent"], expected);
assert_eq!(headers["originator"], originator);
assert_eq!(headers["version"], "0.157.0");
}
assert_eq!(
fixed.resolve(&state).unwrap().user_agent(),
frozen.user_agent()
);
assert_eq!(
custom.resolve(&state).unwrap().user_agent(),
frozen.user_agent()
);
assert_eq!(frozen.codex_version, "0.155.0");
let restarted = CodexWireProfileState::new(super::wire_profile());
CliReleaseService::new(provider, restarted.clone(), cache)
.unwrap()
.restore()
.await;
assert_eq!(
automatic.resolve(&restarted).unwrap(),
automatic.resolve(&state).unwrap()
);
}
@@ -0,0 +1,142 @@
use gateway_core::account::OpaqueProviderData;
use provider_openai::transport::headers::build_codex_model_headers;
use provider_openai::transport::profile::identity::RequestProfileSelection;
use provider_openai::transport::profile::selection::ClientProfileSelection;
use provider_openai::transport::profile::{
CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use serde_json::{Value, json};
fn document(value: Value) -> OpaqueProviderData {
OpaqueProviderData::new(value.as_object().unwrap().clone())
}
#[test]
fn complete_custom_identities_send_exact_user_agent_and_matching_companion_headers() {
let state = CodexWireProfileState::new(CodexWireProfile {
residency: Some(CodexResidency::Us),
..Default::default()
});
for (core, originator, user_agent) in [
(
"0.155.0-alpha.16.3",
"Codex Desktop",
"Codex Desktop/0.155.0-alpha.16.3 (Ubuntu 24.4.0; x86_64) unknown (Codex Desktop; 26.917.62051)",
),
(
"0.156.1",
"codex-tui",
"codex-tui/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex-tui; 0.156.1)",
),
(
"0.156.1",
"codex_exec",
"codex_exec/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex_exec; 0.156.1)",
),
] {
let selection = document(json!({
"mode":"custom", "userAgent":user_agent
}));
let profile = RequestProfileSelection::parse(&selection)
.unwrap()
.resolve(&state)
.unwrap();
let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap();
assert_eq!(headers["user-agent"], user_agent);
assert_eq!(headers["originator"], originator);
assert_eq!(headers["version"], core);
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert!(profile.desktop_build.is_empty());
let frozen: CodexWireProfile =
serde_json::from_value(serde_json::to_value(&profile).unwrap()).unwrap();
assert_eq!(frozen.user_agent(), user_agent);
}
assert!(state.snapshot().exact_user_agent.is_none());
}
#[test]
fn custom_known_prefix_derives_headers_and_unknown_identity_requires_them() {
let state = CodexWireProfileState::new(CodexWireProfile::default());
for (user_agent, originator, core, recognized) in [
(
"codex_cli_rs/0.156.1 (Linux 6.8.0; x86_64) custom-terminal",
"codex_cli_rs",
"0.156.1",
true,
),
(
"My Agent/7 (custom environment)",
"my-agent",
"0.156.0",
false,
),
] {
let mut configuration = json!({"mode":"custom","userAgent":user_agent});
if !recognized {
assert!(RequestProfileSelection::parse(&document(configuration.clone())).is_err());
configuration["originator"] = json!(originator);
configuration["codexVersion"] = json!(core);
}
let configuration = document(configuration);
let profile = RequestProfileSelection::parse(&configuration)
.unwrap()
.resolve(&state)
.unwrap();
let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap();
assert_eq!(headers["user-agent"], user_agent);
assert_eq!(headers["originator"], originator);
assert_eq!(headers["version"], core);
let preview = state.preview_selection(&configuration).unwrap();
assert_eq!(preview.expose_to_provider()["recognized"], recognized);
assert_eq!(preview.expose_to_provider()["versionSource"], "custom");
assert!(preview.expose_to_provider()["verifiedAt"].is_null());
}
}
#[test]
fn raw_identity_rejects_injection_conflicting_headers_and_unknown_modes() {
for configuration in [
json!({"mode":"custom","userAgent":"codex-tui/0.156.1\r\nx-injected: yes"}),
json!({"mode":"custom","userAgent":"codex-tui/0.156.1\tterminal"}),
json!({"mode":"custom","userAgent":"自定义UA","originator":"custom","codexVersion":"0.156.1"}),
json!({"mode":"custom","userAgent":"x".repeat(4097),"originator":"custom","codexVersion":"0.156.1"}),
json!({"mode":"custom","userAgent":"codex-tui/0.156.1","originator":"codex_exec"}),
json!({"mode":"custom","userAgent":"codex-tui/0.156.1","codexVersion":"0.155.0"}),
json!({"mode":"custom","userAgent":"codex-tui/latest","originator":"codex-tui","codexVersion":"0.156.1"}),
json!({"mode":"custom","userAgent":"custom","originator":"agent\r\nx-injected: yes","codexVersion":"0.156.1"}),
json!({"mode":"custom","userAgent":"custom","originator":"agent","codexVersion":"latest"}),
json!({"mode":"custom","userAgent":"codex-tui/0.156.1","extraHeader":"anything"}),
json!({"mode":"unknown","client":"cli","platform":"linux","versionMode":"latest"}),
json!({"mode":null,"client":"cli","platform":"linux","versionMode":"latest"}),
] {
assert!(
RequestProfileSelection::parse(&document(configuration.clone())).is_err(),
"{configuration}"
);
}
}
#[test]
fn old_configuration_and_old_frozen_profile_keep_their_existing_identity() {
let state = CodexWireProfileState::new(CodexWireProfile::default());
let legacy = ClientProfileSelection::default();
let resolved = legacy.resolve(&state).unwrap();
assert_eq!(
RequestProfileSelection::parse(&legacy.document().unwrap())
.unwrap()
.resolve(&state)
.unwrap(),
resolved
);
let old_document = serde_json::to_value(&resolved).unwrap();
assert!(old_document.get("exact_user_agent").is_none());
let restored: CodexWireProfile = serde_json::from_value(old_document).unwrap();
assert_eq!(restored.user_agent(), resolved.user_agent());
assert_eq!(
state
.preview_selection(&legacy.document().unwrap())
.unwrap()
.expose_to_provider()["versionSource"],
"official"
);
}
@@ -17,6 +17,7 @@ use provider_openai::transport::profile::{
mod cli_release;
mod desktop_artifact;
mod identity;
mod platform_release;
mod selection;
@@ -148,6 +149,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
@@ -352,6 +354,7 @@ fn wire_profile() -> CodexWireProfile {
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
@@ -1,6 +1,6 @@
use gateway_core::account::OpaqueProviderData;
use provider_openai::transport::profile::selection::{
ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
};
use provider_openai::transport::profile::{CodexBundledReleaseProfile, CodexWireProfileState};
use serde_json::json;
@@ -92,6 +92,9 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() {
for patch in [
json!({"originator":"client\r\nx-injected: yes"}),
json!({"terminal":"term; forged"}),
json!({"osType":"Linux; forged"}),
json!({"cliEntry":"unknown"}),
json!({"client":"desktop", "cliEntry":"tui"}),
json!({"arch":""}),
json!({"codexVersion":"0.155.0"}),
json!({"desktopVersion":"26.1.0"}),
@@ -106,3 +109,25 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() {
assert!(ClientProfileSelection::parse(&OpaqueProviderData::new(fields)).is_err());
}
}
#[test]
fn cli_entry_suffix_keeps_its_name_when_originator_is_overridden() {
let state = CodexWireProfileState::new(super::wire_profile());
let selection = ClientProfileSelection {
client: ClientKind::Cli,
platform: ClientPlatform::Linux,
cli_entry: Some(CliEntry::Exec),
originator: Some("my-agent".into()),
os_type: Some("Alpine Linux".into()),
os_version: Some("3.24.1".into()),
version_mode: VersionMode::Fixed,
codex_version: Some("0.157.0".into()),
..Default::default()
};
let profile = selection.resolve(&state).unwrap();
assert_eq!(
profile.user_agent(),
"my-agent/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown (codex_exec; 0.157.0)"
);
assert_eq!(profile.originator, "my-agent");
}
@@ -28,6 +28,7 @@ fn client(base_url: &str) -> CodexBackendClient {
os_version: "15.5.0".to_owned(),
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
@@ -823,6 +823,7 @@ fn profile() -> CodexWireProfileState {
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
exact_user_agent: None,
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
+16 -3
View File
@@ -1261,21 +1261,32 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI
该配置作用于 Client Key 的 OpenAI 模型请求与原生模型目录,适用于 HTTP/SSE、WebSocket、Images 和 Search。
不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作。
身份对象字段如下,可选字段省略或 `null` 时使用所选预设参数:
自定义配置使用 `{ "mode": "custom", "userAgent": "完整 UA" }`。
已识别的 `Codex Desktop`、`codex-tui`、`codex_exec`、`codex_cli_rs` 前缀由后端解析 `originator` 和 Core `version`,
显式提供的配套字段必须与识别结果一致。未知前缀须另填 `originator` 和 `codexVersion`,这两个字段与 UA 一起发送。
UA 须为 1 至 4096 字节的单行可见 ASCII 文本,首尾不能含空白;`originator` 最多 128 字节,
`codexVersion` 最多 64 字节并须符合 SemVer。自定义配置不要求 Desktop 构建号,也不自动更新。
没有 `mode` 字段的预设配置按以下合同解析,可选字段省略或 `null` 时使用所选预设参数:
| 字段 | 取值与语义 |
| --- | --- |
| `client` | 必填,`desktop` 或 `cli` |
| `platform` | 必填,`macos`、`linux` 或 `windows` |
| `versionMode` | 必填,`latest` 或 `fixed` |
| `originator`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 |
| `cliEntry` | CLI 可选 `tui` 或 `exec`,省略或 `null` 保留 Core 默认身份;Desktop 不接受此字段 |
| `originator`、`osType`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 |
| `codexVersion` | `fixed` 必填的 Core SemVer;`latest` 必须省略或为 `null` |
| `desktopVersion`、`desktopBuild` | 仅 Desktop 的 `fixed` 模式必填,分别为数字点分版本和数字构建号;CLI 不接受这些字段 |
```json
{ "client": "cli", "platform": "linux", "versionMode": "latest" }
{ "client": "cli", "platform": "linux", "versionMode": "latest", "cliEntry": "tui", "osType": "Alpine Linux", "osVersion": "3.24.1", "terminal": "xterm-256color" }
```
TUI 默认标识为 `codex-tui`,Exec 为 `codex_exec`,入口后缀使用同一次解析的 Core 版本。
`originator` 覆盖只更改产品名前缀和配套头,后缀仍表示所选入口。省略 `osType` 使用平台名称;
自定义运行环境在自动更新时保持不变。旧配置未指定 `cliEntry` 时继续使用 `codex_cli_rs` 默认值且不添加入口后缀。
六套预设均支持自动更新:macOS Desktop 支持 arm64,Windows/Linux Desktop 及三套 CLI 支持 arm64、x86_64。
预设接口的 `automaticAvailable`、`reason` 表示当前组合的可用性;自定义架构可能使自动解析不可用。
每 24 小时后台检查官方稳定发布,失败保留同组合上次有效版本;固定值不受后台更新影响。
@@ -1284,7 +1295,9 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI
预览返回 `configuration`、`source`(`global` / `override`)、`userAgent`、解析后的环境和版本字段,
以及 `versionSource`(`official` / `custom`)、`verifiedAt`、`checkedAt`、`error`。
自定义预览中的 `recognized` 表示是否识别出配套请求头。
`verifiedAt` 只表示版本资料核验,不能代表自定义运行环境或 TLS 已核验;固定版本返回 `null`。
完整自定义配置不携带官方制品核验时间。
客户端画像配置控制应用层请求字段,不切换操作系统的 TLS 实现。默认 HTTP 使用 native TLS,
WebSocket 使用 rustls;配置自定义 CA 时 HTTP 也使用 rustls。TLS 指纹需按实际部署平台与传输路径核验。
未完成本次启动检查时 `checkedAt` 为 `null`。非法或当前不可用的选择返回 `400`,保存失败不提交其他修改。
+4 -1
View File
@@ -326,7 +326,10 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配
各 Provider 唯一负责默认值、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。
首次初始化只写入内置默认选择;YAML 不定义客户端身份,也不作为数据库初始化或请求解析的来源。
官方发布资料与用户选择分开:OpenAI 在 Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存,
Desktop 完整制品元组原子更新,固定配置不被刷新覆盖。普通连接按已有身份键匹配,精确续写保留原连接。
Desktop 完整制品元组原子更新。CLI 的 TUI/Exec 入口共用官方 CLI 版本资料,Provider 在一次身份解析中
生成一致的 UA、入口后缀与配套请求头,后台更新不修改用户选择的运行环境。
完整自定义 UA 经 Provider 校验后原样传递,配套头统一解析;固定和自定义配置不受后台更新影响。
普通连接按已有身份键匹配,精确续写保留原连接。
后台账号和 Desktop 专属操作使用独立官方 Desktop 画像,不接受 Key 覆盖。
xAI 以内置画像为版本检查基线,在进程内更新 Grok CLI 发布资料;模型与压缩请求使用已保存的用户选择,
OAuth、后台目录和额度查询使用内置官方画像。两者均不回写 `config.yaml`。
+22 -4
View File
@@ -4,10 +4,13 @@ export type ProviderRequestProfile = Record<string, unknown>
export type ProviderRequestProfiles = Record<string, ProviderRequestProfile>
export type ProviderRequestProfileUpdates = Record<string, ProviderRequestProfile | null>
export interface ClientProfileSelection {
export interface PresetClientProfileSelection {
mode?: undefined
client: 'desktop' | 'cli'
platform: 'macos' | 'linux' | 'windows'
versionMode: 'latest' | 'fixed'
cliEntry?: 'tui' | 'exec' | null
osType?: string | null
originator: string | null
osVersion: string | null
arch: string | null
@@ -17,6 +20,20 @@ export interface ClientProfileSelection {
desktopBuild: string | null
}
export interface CustomClientProfileSelection {
mode: 'custom'
userAgent: string
originator?: string | null
codexVersion?: string | null
}
export type ClientProfileSelection = PresetClientProfileSelection | CustomClientProfileSelection
export interface ClientProfileOptions {
presets: ClientProfilePreset[]
globalConfiguration: ClientProfileSelection
}
export interface ClientProfilePreview {
configuration: ClientProfileSelection
source: 'global' | 'override'
@@ -30,20 +47,21 @@ export interface ClientProfilePreview {
desktopBuild: string | null
userAgent: string
versionSource: 'official' | 'custom'
recognized?: boolean
verifiedAt: string | null
checkedAt: string | null
error: string | null
}
export interface ClientProfilePreset {
configuration: ClientProfileSelection
configuration: PresetClientProfileSelection
automaticAvailable: boolean
reason: string | null
defaults: Pick<ClientProfileSelection, 'originator' | 'osVersion' | 'arch' | 'terminal'>
defaults: Pick<PresetClientProfileSelection, 'originator' | 'osType' | 'osVersion' | 'arch' | 'terminal'>
}
export function getClientProfileOptions() {
return request<{ presets: ClientProfilePreset[], globalConfiguration: ClientProfileSelection }>({
return request<ClientProfileOptions>({
url: '/api/admin/settings/client-profiles/openai',
method: 'GET',
silent: true,
@@ -1,9 +1,14 @@
<script setup lang="ts">
import type { ClientProfilePreset, ClientProfilePreview, ClientProfileSelection } from '@/api/modules/client-profiles'
import { BaseButton, BaseFormItem, BaseInput, BaseSegmented, BaseSelect } from '@codex-proxy/ui'
import type {
ClientProfileOptions,
ClientProfilePreview,
ClientProfileSelection,
} from '@/api/modules/client-profiles'
import { BaseButton, BaseSegmented } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getClientProfileOptions, previewClientProfile } from '@/api/modules/client-profiles'
import { errorMessage } from '@/utils/async'
import ClientProfilePresetFields from './ClientProfilePresetFields.vue'
import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue'
const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), {
@@ -12,74 +17,39 @@ const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, a
allowInherit: false,
})
const model = defineModel<ClientProfileSelection | null>({ required: true })
const presets = shallowRef<ClientProfilePreset[]>([])
const globalConfiguration = shallowRef<ClientProfileSelection>()
const options = shallowRef<ClientProfileOptions>()
const preview = shallowRef<ClientProfilePreview>()
const loading = shallowRef(true)
const loadError = shallowRef('')
const previewError = shallowRef('')
const previewing = shallowRef(false)
const platforms = { macos: 'MacOS', linux: 'Linux', windows: 'Windows' }
const presetOptions = computed(() => presets.value.map(({ configuration }) => ({
value: `${configuration.platform}-${configuration.client}`,
label: `${platforms[configuration.platform]} · ${configuration.client === 'desktop' ? 'Desktop' : 'CLI'}`,
})))
const currentPreset = computed(() => presets.value.find(({ configuration }) =>
configuration.client === model.value?.client && configuration.platform === model.value?.platform,
))
const needsVersionInput = computed(() => model.value?.versionMode === 'fixed'
&& (!model.value.codexVersion || (model.value.client === 'desktop' && (!model.value.desktopVersion || !model.value.desktopBuild))))
const independentDraft = shallowRef<ClientProfileSelection>()
const effective = computed(() => model.value ?? options.value?.globalConfiguration)
const inherited = computed(() => props.allowInherit && model.value === null)
const preset = computed(() => effective.value && !effective.value.mode ? effective.value : undefined)
const custom = computed(() => effective.value?.mode === 'custom' || preset.value?.versionMode === 'fixed')
const needsInput = computed(() => effective.value?.mode === 'custom'
? !effective.value.userAgent.trim()
: preset.value?.versionMode === 'fixed'
&& (!preset.value.codexVersion || (preset.value.client === 'desktop' && (!preset.value.desktopVersion || !preset.value.desktopBuild))))
const profileSource = computed({
get: () => model.value === null ? 'global' : 'independent',
set: (value: string) => {
if (value === 'global')
if (value === 'global') {
if (model.value)
independentDraft.value = { ...model.value }
model.value = null
else if (globalConfiguration.value)
model.value = { ...globalConfiguration.value }
},
})
const selectedPreset = computed({
get: () => model.value ? `${model.value.platform}-${model.value.client}` : '',
set: (value: string) => {
const preset = presets.value.find(({ configuration }) => `${configuration.platform}-${configuration.client}` === value)
if (preset)
model.value = { ...preset.configuration, versionMode: preset.automaticAvailable ? 'latest' : 'fixed' }
},
})
const versionMode = computed({
get: () => model.value?.versionMode ?? 'latest',
set: (value: string) => {
if (!model.value)
return
const fixed = value === 'fixed'
model.value = {
...model.value,
versionMode: fixed ? 'fixed' : 'latest',
codexVersion: fixed ? preview.value?.codexVersion ?? null : null,
desktopVersion: fixed ? preview.value?.desktopVersion ?? null : null,
desktopBuild: fixed ? preview.value?.desktopBuild ?? null : null,
}
else if (independentDraft.value ?? options.value?.globalConfiguration) {
model.value = { ...(independentDraft.value ?? options.value!.globalConfiguration) }
}
},
})
const customFields = [
{ key: 'originator', label: '客户端标识' },
{ key: 'osVersion', label: '系统版本' },
{ key: 'arch', label: 'CPU 架构' },
{ key: 'terminal', label: '终端标记' },
] as const
function updateField(key: keyof ClientProfileSelection, value: string) {
if (model.value)
model.value = { ...model.value, [key]: value || null }
}
async function load() {
loading.value = true
loadError.value = ''
try {
const options = await getClientProfileOptions()
presets.value = options.presets
globalConfiguration.value = options.globalConfiguration
options.value = await getClientProfileOptions()
}
catch (error) {
loadError.value = errorMessage(error)
@@ -93,8 +63,8 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
let cancelled = false
preview.value = undefined
previewError.value = ''
previewing.value = active && !needsVersionInput.value
if (!active || needsVersionInput.value)
previewing.value = active && !needsInput.value
if (!active || needsInput.value)
return
const timer = setTimeout(async () => {
try {
@@ -104,7 +74,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
}
catch (error) {
if (!cancelled)
previewError.value = errorMessage(error)
previewError.value = errorMessage(error).replaceAll('User-Agent', '用户代理')
}
finally {
if (!cancelled)
@@ -117,7 +87,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
})
}, { immediate: true })
onMounted(load)
onMounted(() => load())
</script>
<template>
@@ -131,63 +101,35 @@ onMounted(load)
{ label: '全局配置', value: 'global' },
{ label: '独立配置', value: 'independent' },
]"
:disabled="disabled || loading || !!loadError"
:disabled="disabled || loading || !options"
/>
<slot name="source-extra" />
</div>
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<div v-if="loadError" role="alert" class="flex flex-wrap items-center justify-between gap-3 text-cp text-cp-error">
<span>预设加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
<BaseButton size="sm" :disabled="disabled || loading" @click="load()">
重试
</BaseButton>
</div>
<p v-else-if="loading" role="status" class="m-0 text-cp text-cp-text-secondary">
正在加载客户端预设…
<p v-if="loading" role="status" class="m-0 text-cp text-cp-text-secondary">
正在加载客户端身份…
</p>
<template v-else>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端预设">
<BaseSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect
v-model="versionMode"
class="w-full"
:options="[
{ label: '跟随最新版本', value: 'latest', disabled: !currentPreset?.automaticAvailable },
{ label: '自定义版本', value: 'fixed' },
]"
:disabled="disabled"
/>
</BaseFormItem>
</div>
<p v-if="currentPreset?.reason" class="m-0 text-cp-sm text-cp-text-secondary">
{{ currentPreset.reason }}
</p>
<div v-if="model.versionMode === 'fixed'" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="Codex Core 版本" required>
<BaseInput :model-value="model.codexVersion ?? ''" :disabled="disabled" placeholder="例如 0.155.0" @update:model-value="updateField('codexVersion', $event)" />
</BaseFormItem>
<template v-if="model.client === 'desktop'">
<BaseFormItem label="Desktop 版本" required>
<BaseInput :model-value="model.desktopVersion ?? ''" :disabled="disabled" placeholder="填写该制品的应用版本" @update:model-value="updateField('desktopVersion', $event)" />
</BaseFormItem>
<BaseFormItem label="Desktop 构建号" required>
<BaseInput :model-value="model.desktopBuild ?? ''" :disabled="disabled" placeholder="填写该制品的构建号" @update:model-value="updateField('desktopBuild', $event)" />
</BaseFormItem>
</template>
<BaseFormItem v-for="field in customFields" :key="field.key" :label="field.label">
<BaseInput
:model-value="model[field.key] ?? ''"
:placeholder="currentPreset?.defaults[field.key] ?? ''"
:disabled="disabled"
@update:model-value="updateField(field.key, $event)"
/>
</BaseFormItem>
</div>
</template>
</template>
<ClientProfilePreviewPanel :preview="preview" :previewing="previewing" :needs-version-input="needsVersionInput" :error="previewError" />
<ClientProfilePresetFields
v-else-if="!inherited"
:model-value="effective ?? null"
:presets="options?.presets ?? []"
:preview="preview"
:previewing="previewing"
:error="previewError"
:disabled="disabled"
@update:model-value="model = $event"
/>
<ClientProfilePreviewPanel
v-if="inherited || !custom"
:preview="preview"
:previewing="previewing"
:needs-version-input="!!needsInput"
:error="previewError"
/>
</div>
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { ClientProfilePreset, ClientProfilePreview, ClientProfileSelection, CustomClientProfileSelection, PresetClientProfileSelection } from '@/api/modules/client-profiles'
import { BaseFormItem, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
const props = defineProps<{
presets: ClientProfilePreset[]
preview?: ClientProfilePreview
previewing: boolean
error: string
disabled: boolean
}>()
const model = defineModel<ClientProfileSelection | null>({ required: true })
const presetDraft = shallowRef<PresetClientProfileSelection>()
const customDraft = shallowRef<CustomClientProfileSelection>()
const preset = computed(() => model.value && !model.value.mode ? model.value : undefined)
const custom = computed(() => model.value?.mode === 'custom' || preset.value?.versionMode === 'fixed')
const platforms = { macos: 'MacOS', linux: 'Linux', windows: 'Windows' }
const presetOptions = computed(() => props.presets.map(({ configuration }) => ({
value: `${configuration.platform}-${configuration.client}`,
label: `${platforms[configuration.platform]} · ${configuration.client === 'desktop' ? 'Desktop' : 'CLI'}`,
})))
const currentPreset = computed(() => props.presets.find(({ configuration }) =>
configuration.client === preset.value?.client && configuration.platform === preset.value?.platform,
))
const selectedPreset = computed({
get: () => {
const selection = preset.value ?? presetDraft.value
return selection ? `${selection.platform}-${selection.client}` : ''
},
set: (value: string) => {
const selected = props.presets.find(({ configuration }) => `${configuration.platform}-${configuration.client}` === value)
if (selected) {
customDraft.value = undefined
model.value = { ...selected.configuration, versionMode: selected.automaticAvailable ? 'latest' : 'fixed' }
}
},
})
const cliEntry = computed({
get: () => preset.value?.cliEntry ?? 'core',
set: (value: string) => {
if (preset.value && (value === 'core' || value === 'tui' || value === 'exec')) {
customDraft.value = undefined
model.value = { ...preset.value, cliEntry: value === 'core' ? null : value }
}
},
})
const versionMode = computed({
get: () => custom.value ? 'custom' : 'latest',
set: (value: string) => {
if (value === 'custom') {
if (!props.preview && !customDraft.value)
return
if (preset.value)
presetDraft.value = { ...preset.value }
model.value = { ...(customDraft.value ?? { mode: 'custom', userAgent: props.preview!.userAgent }) }
}
else {
if (model.value?.mode === 'custom')
customDraft.value = { ...model.value }
const selection = preset.value ?? presetDraft.value ?? props.presets[0]?.configuration
if (selection) {
model.value = { ...selection, versionMode: 'latest', codexVersion: null, desktopVersion: null, desktopBuild: null }
}
}
},
})
const userAgent = computed({
get: () => model.value?.mode === 'custom' ? model.value.userAgent : props.preview?.userAgent ?? '',
set: (value: string) => {
const previous = model.value?.mode === 'custom' ? model.value : undefined
if (preset.value)
presetDraft.value = { ...preset.value }
// 已知客户端的配套头重新派生,避免修改版本后继续携带旧值。
const recognized = /^(?:Codex Desktop|codex-tui|codex_cli_rs|codex_exec)\/\S+(?:\s|$)/.test(value)
model.value = {
mode: 'custom',
userAgent: value,
...(!recognized && previous ? { originator: previous.originator, codexVersion: previous.codexVersion } : {}),
}
},
})
</script>
<template>
<div class="grid gap-4">
<div class="grid gap-4" :class="preset?.client === 'cli' && !custom ? 'sm:grid-cols-3' : 'sm:grid-cols-2'">
<BaseFormItem label="客户端预设">
<BaseSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :placeholder="custom ? '使用自定义用户代理' : undefined" :disabled="disabled || custom || !presets.length" />
</BaseFormItem>
<BaseFormItem v-if="preset?.client === 'cli' && !custom" label="CLI 入口">
<BaseSelect
v-model="cliEntry"
class="w-full"
:options="[
{ label: 'Core · 默认身份', value: 'core' },
{ label: 'TUI · 交互式终端', value: 'tui' },
{ label: 'Exec · 非交互执行', value: 'exec' },
]"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect
v-model="versionMode"
class="w-full"
:options="[
{ label: '跟随最新版本', value: 'latest', disabled: !presets.length },
{ label: '自定义版本', value: 'custom', disabled: !custom && (previewing || !preview) },
]"
:disabled="disabled"
/>
</BaseFormItem>
</div>
<p v-if="!custom && currentPreset?.reason" class="m-0 text-cp-sm text-cp-text-secondary">
{{ currentPreset.reason }}
</p>
<template v-if="custom">
<BaseTextarea
v-model="userAgent"
:rows="3"
:disabled="disabled || (!model?.mode && previewing)"
aria-label="用户代理"
placeholder="填写完整用户代理"
spellcheck="false"
autocomplete="off"
class="font-mono"
/>
<p v-if="error" role="alert" class="m-0 text-cp-sm text-cp-error">
{{ error }}
</p>
</template>
</div>
</template>
@@ -12,30 +12,35 @@ defineProps<{
</script>
<template>
<div class="grid min-h-20 min-w-0 content-center gap-2 rounded-cp bg-cp-fill-quaternary p-4" aria-live="polite" :aria-busy="previewing">
<p v-if="needsVersionInput" class="m-0 text-cp-sm text-cp-text-tertiary">
填写版本后预览
</p>
<div v-else-if="previewing" class="grid gap-2" role="status" aria-label="正在解析客户端身份">
<div class="flex h-lh items-center text-cp-sm" aria-hidden="true">
<BaseSkeleton shape="text" class="w-4/5" />
<p v-if="needsVersionInput" role="status" class="m-0 text-cp-sm text-cp-text-tertiary">
填写版本后预览
</p>
<p v-else-if="error && !previewing" role="alert" class="m-0 text-cp-sm text-cp-error">
{{ error }}
</p>
<div
v-else-if="previewing || preview"
class="grid min-h-20 min-w-0 content-center gap-2 rounded-cp bg-cp-fill-quaternary p-4"
aria-live="polite"
:aria-busy="previewing"
>
<template v-if="previewing">
<div class="flex h-lh items-center text-cp-sm" role="status" aria-label="正在解析客户端身份">
<BaseSkeleton shape="text" class="w-4/5" aria-hidden="true" />
</div>
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
<BaseSkeleton shape="text" class="w-52 max-w-full" />
</div>
</div>
<p v-else-if="error" role="alert" class="m-0 text-cp-sm text-cp-error">
{{ error }}
</p>
</template>
<template v-else-if="preview">
<code class="break-all text-cp-sm text-cp-text">{{ preview.userAgent }}</code>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
{{ preview.versionSource === 'custom' ? '自定义版本' : '自动更新' }}
{{ preview.versionSource === 'custom' ? '固定身份' : '自动更新' }}
<template v-if="preview.versionSource === 'official'">
· {{ preview.checkedAt ? `检查于 ${formatDateTime(preview.checkedAt)}` : '待检查' }}
</template>
</p>
<p v-if="preview.error && preview.versionSource === 'official'" :title="preview.error" class="m-0 text-cp-sm text-cp-warning">
<p v-if="preview.error && preview.versionSource !== 'custom'" :title="preview.error" class="m-0 text-cp-sm text-cp-warning">
更新失败 · 沿用上次版本
</p>
</template>
@@ -42,7 +42,7 @@ async function logout() {
</template>
<template #actions>
<div class="flex max-w-[calc(100vw-32px)] flex-wrap items-center justify-end gap-2">
<BaseIconButton label="关于" variant="filled" @click="$emit('openAbout')">
<BaseIconButton label="关于" variant="secondary" @click="$emit('openAbout')">
<Info class="size-4" />
</BaseIconButton>
<BaseButton variant="secondary" :loading="configuring" @click="$emit('configure')">
@@ -51,7 +51,7 @@ async function logout() {
</BaseButton>
<BaseSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
<BaseSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
<BaseIconButton label="刷新用量" variant="filled" :loading="refreshing" @click="$emit('refresh')">
<BaseIconButton label="刷新用量" variant="secondary" :loading="refreshing" @click="$emit('refresh')">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton label="切换主题" @click="theme.toggleTheme($event)">
@@ -49,29 +49,31 @@ const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
/>
</BaseFormItem>
<BaseFormItem label="标签(可选)">
<BaseInput
v-model="form.label"
aria-label="标签(可选)"
placeholder="例如:后端服务"
:disabled="saving"
/>
</BaseFormItem>
<div class="grid gap-6" :class="{ 'sm:grid-cols-2': !editing }">
<BaseFormItem label="标签(可选)">
<BaseInput
v-model="form.label"
aria-label="标签(可选)"
placeholder="例如:后端服务"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem
v-if="!editing"
label="自定义 Key(可选)"
>
<BaseInput
v-model="form.customKey"
type="password"
autocomplete="new-password"
:spellcheck="false"
aria-label="自定义 Key(可选)"
placeholder="留空自动生成"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem
v-if="!editing"
label="自定义 Key(可选)"
>
<BaseInput
v-model="form.customKey"
type="password"
autocomplete="new-password"
:spellcheck="false"
aria-label="自定义 Key(可选)"
placeholder="留空自动生成"
:disabled="saving"
/>
</BaseFormItem>
</div>
<BaseFormItem label="分组">
<AccountGroupCheckboxGrid
@@ -67,7 +67,7 @@ export function useSettingsForm() {
const saved = shallowRef<ReturnType<typeof snapshot>>()
const loaded = computed(() => saved.value !== undefined)
const hasChanges = computed(() => loaded.value && JSON.stringify(snapshot()) !== JSON.stringify(saved.value))
const hasChanges = computed(() => loaded.value && !isEqual(snapshot(), saved.value))
function resetSettings() {
if (!saved.value || saving.value)
+1 -1
View File
@@ -107,7 +107,7 @@ watch(section, (value) => {
/>
<div v-if="isBasicSection || hasChanges" class="ml-auto flex items-center justify-end gap-2">
<span v-if="hasChanges" class="mr-1 size-1.5 shrink-0 rounded-full bg-cp-warning" aria-hidden="true" />
<BaseIconButton v-if="hasChanges" label="撤销全部基础设置更改" variant="filled" :disabled="saving || loading" @click="resetSettings">
<BaseIconButton v-if="hasChanges" label="撤销全部基础设置更改" variant="secondary" :disabled="saving || loading" @click="resetSettings">
<Undo2 class="size-4" />
</BaseIconButton>
<BaseButton variant="primary" :loading="saving" :disabled="loading || !hasChanges || !!error" @click="saveSettings">