From 1acd4a5f5941beee109ef33a055f47dfe4285fec Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:39:29 +0800 Subject: [PATCH] feat(settings): unify OpenAI and xAI client identities (#174) Co-authored-by: Zyy Co-authored-by: Codex --- backend/apps/gateway/src/bootstrap.rs | 8 +- backend/apps/gateway/tests/architecture.rs | 5 +- backend/apps/gateway/tests/bootstrap.rs | 63 +++---- backend/crates/gateway-admin/src/lib.rs | 4 +- .../gateway-admin/src/model/client_keys.rs | 4 + .../gateway-admin/src/model/settings.rs | 15 ++ .../gateway-admin/src/use_case/client_keys.rs | 39 ++-- .../src/use_case/observability.rs | 6 + .../gateway-admin/src/use_case/settings.rs | 43 +++-- .../gateway-admin/tests/use_case/accounts.rs | 1 + .../tests/use_case/client_keys.rs | 3 + .../tests/use_case/freeze_recovery.rs | 1 + .../tests/use_case/observability.rs | 1 + .../gateway-admin/tests/use_case/settings.rs | 15 ++ .../gateway-api/src/admin/client_keys.rs | 13 ++ .../crates/gateway-api/src/admin/settings.rs | 21 ++- .../gateway-api/tests/admin/client_keys.rs | 18 ++ backend/crates/gateway-api/tests/admin/mod.rs | 3 + .../gateway-api/tests/admin/settings.rs | 21 ++- .../gateway-api/tests/key_usage/fixtures.rs | 1 + .../crates/gateway-store/src/admin_adapter.rs | 2 + .../gateway-store/src/postgres/client_keys.rs | 33 +++- .../src/postgres/runtime_settings.rs | 12 +- .../tests/postgres/account_groups.rs | 3 + .../tests/postgres/client_budgets.rs | 3 + .../tests/postgres/client_keys.rs | 24 +++ .../tests/postgres/runtime_settings.rs | 51 +++++ backend/crates/providers/openai/src/config.rs | 175 +----------------- backend/crates/providers/openai/src/lib.rs | 16 +- .../openai/src/transport/profile/mod.rs | 21 ++- .../crates/providers/openai/tests/admin.rs | 26 +-- .../crates/providers/openai/tests/config.rs | 84 ++------- .../openai/tests/credential/admin.rs | 4 +- .../openai/tests/credential/oauth.rs | 3 +- .../openai/tests/credential/refresh.rs | 2 +- .../openai/tests/credential/token_client.rs | 5 +- .../openai/tests/transport/headers.rs | 2 +- .../tests/transport/profile/selection.rs | 3 + backend/crates/providers/xai/src/admin.rs | 62 ++++++- backend/crates/providers/xai/src/config.rs | 76 -------- backend/crates/providers/xai/src/lib.rs | 28 +-- .../crates/providers/xai/src/provider/mod.rs | 40 +++- .../xai/src/transport/client_profile.rs | 131 +++++++++++++ .../crates/providers/xai/src/transport/mod.rs | 3 +- .../providers/xai/src/transport/network.rs | 2 + .../providers/xai/src/transport/profile.rs | 57 ++++-- backend/crates/providers/xai/tests/admin.rs | 106 +++++++---- backend/crates/providers/xai/tests/config.rs | 22 --- backend/crates/providers/xai/tests/main.rs | 1 - .../providers/xai/tests/provider/contract.rs | 52 ++++++ backend/crates/providers/xai/tests/support.rs | 19 +- .../xai/tests/transport/client_profile.rs | 58 ++++++ .../providers/xai/tests/transport/headers.rs | 27 +++ .../providers/xai/tests/transport/mod.rs | 1 + .../providers/xai/tests/transport/network.rs | 92 +++++++++ .../providers/xai/tests/transport/profile.rs | 23 ++- deploy/README.md | 14 +- deploy/config.example.yaml | 18 +- docs/api.md | 33 +++- docs/architecture.md | 10 +- frontend/src/api/modules/api-keys.ts | 4 +- frontend/src/api/modules/client-profiles.ts | 36 ++++ frontend/src/api/modules/settings.ts | 3 +- .../client-profile/ClientProfileEditor.vue | 32 +--- .../ClientProfilePreviewPanel.vue | 43 +++++ .../client-profile/XaiClientProfileEditor.vue | 136 ++++++++++++++ .../keys/components/ApiKeyCreateModal.vue | 6 +- .../keys/composables/useApiKeyMutations.ts | 6 +- .../settings/components/ClientProfileCard.vue | 64 +++++++ .../settings/composables/useSettingsForm.ts | 8 +- frontend/src/views/settings/index.vue | 12 +- 71 files changed, 1356 insertions(+), 623 deletions(-) delete mode 100644 backend/crates/providers/xai/src/config.rs create mode 100644 backend/crates/providers/xai/src/transport/client_profile.rs delete mode 100644 backend/crates/providers/xai/tests/config.rs create mode 100644 backend/crates/providers/xai/tests/transport/client_profile.rs create mode 100644 frontend/src/components/client-profile/ClientProfilePreviewPanel.vue create mode 100644 frontend/src/components/client-profile/XaiClientProfileEditor.vue create mode 100644 frontend/src/views/settings/components/ClientProfileCard.vue diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index ebee62c91..5bd39b8d4 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -17,8 +17,8 @@ pub struct GatewayConfig { #[serde(default)] client: gateway_admin::ClientConfig, api: gateway_api::ApiConfig, + #[serde(default)] openai: provider_openai::OpenAiConfig, - xai: provider_xai::XaiConfig, } impl LoadableConfig for GatewayConfig { @@ -46,9 +46,6 @@ impl LoadableConfig for GatewayConfig { self.openai .resolve_and_validate(&runtime_data_dir) .map_err(|_| ConfigError::InvalidField("openai"))?; - self.xai - .resolve_and_validate(source_dir) - .map_err(|_| ConfigError::InvalidField("xai"))?; Ok(()) } } @@ -64,7 +61,6 @@ pub async fn run() -> Result<(), BootstrapError> { client, api, openai, - xai, } = config; let host = gateway_host::initialize(host).await?; @@ -74,7 +70,7 @@ pub async fn run() -> Result<(), BootstrapError> { let provider_ports = store.provider_ports(); let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?; host.report_startup_ready("OpenAI Provider"); - let mut xai = provider_xai::initialize(xai, provider_ports).await?; + let mut xai = provider_xai::initialize(provider_ports).await?; host.report_startup_ready("xAI Provider"); let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?; let mut core = gateway_core::initialize(store.core_ports(), providers).await?; diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index a2a82238b..55c70dce4 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -196,10 +196,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[ "crates/providers/openai", &["config", "credential", "transport"], ), - ( - "crates/providers/xai", - &["config", "credential", "transport"], - ), + ("crates/providers/xai", &["credential", "transport"]), ]; /// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。 diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 3b89cf2fa..b1cc13a70 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -291,9 +291,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() { "/client", "/api", "/openai", - "/openai/wire_profile", - "/xai", - "/xai/wire_profile", ] { let mut extended = document.clone(); extended.pointer_mut(path).expect("configuration section")["unused_setting"] = @@ -364,7 +361,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() { ); match case { "unused" => assert!( - stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"), + stderr.contains("openai.wire_profile") && stderr.contains("已忽略"), "{stderr}" ), "missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"), @@ -380,6 +377,26 @@ fn config_loader_should_reject_missing_explicit_fields() { assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", "")); } +#[test] +fn config_loader_should_allow_missing_provider_configuration() { + let mut document = valid_config_document(); + document.as_object_mut().unwrap().remove("openai"); + document.as_object_mut().unwrap().remove("xai"); + parse_config(&document.to_string()).expect("provider defaults require no YAML identity"); +} + +#[test] +fn config_loader_should_ignore_removed_provider_identity_sections() { + let mut document = valid_config_document(); + document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"}); + document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}}); + let (config, _directory) = + parse_config(&document.to_string()).expect("removed identities ignored"); + let debug = format!("{config:?}"); + assert!(!debug.contains("legacy-invalid")); + assert!(!debug.contains("legacy-client")); +} + #[test] fn config_loader_should_reject_unsupported_schema_version() { assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2")); @@ -444,15 +461,6 @@ fn config_loader_should_reject_missing_client_session_ttl() { assert_rejected(config.to_string()); } -#[test] -fn config_loader_should_reject_invalid_codex_cli_version() { - assert_rejected(valid_config().replacen( - " residency: us", - " residency: us\n codex_version: 'latest'", - 1, - )); -} - #[test] fn config_loader_should_reject_disabled_all_log_outputs() { assert_rejected( @@ -468,31 +476,10 @@ fn config_loader_should_reject_zero_server_port() { } #[test] -fn config_loader_should_reject_invalid_desktop_profile_fields() { - assert_rejected(valid_config().replacen( - " residency: us", - " residency: us\n desktop_build: 'build'", - 1, - )); -} - -#[test] -fn config_loader_should_ignore_removed_request_location_setting() { - let original = valid_config(); - parse_config(&original).expect("location belongs to runtime settings"); - for value in [ - "null", - "{}", - "{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }", - ] { - let legacy = original.replacen( - " residency: us", - &format!(" residency: us\n location: {value}"), - 1, - ); - assert_ne!(legacy, original); - parse_config(&legacy).expect("removed request location does not prevent startup"); - } +fn config_loader_should_validate_openai_residency() { + let mut document = valid_config_document(); + document["openai"]["residency"] = serde_json::json!("invalid"); + assert_rejected(document.to_string()); } fn assert_rejected(config: String) { diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 15838c9f8..26f3969b6 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -375,7 +375,6 @@ pub async fn initialize( store.client_keys(), store.observability(), )); - let profile_provider = Arc::clone(&openai); let openai = Arc::new(DefaultOpenAiService::new( openai, store.accounts(), @@ -409,7 +408,7 @@ pub async fn initialize( client_keys: Arc::new(DefaultClientKeyService::new( store.client_keys(), snapshot.clone(), - Arc::clone(&profile_provider), + registry.clone(), )), client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)), observability: Arc::new(DefaultObservabilityService::new( @@ -421,7 +420,6 @@ pub async fn initialize( settings: Arc::new(DefaultSettingsService::new( store.settings(), snapshot.clone(), - profile_provider, registry, pricing_source, )), diff --git a/backend/crates/gateway-admin/src/model/client_keys.rs b/backend/crates/gateway-admin/src/model/client_keys.rs index 851fba64f..b9bf5f895 100644 --- a/backend/crates/gateway-admin/src/model/client_keys.rs +++ b/backend/crates/gateway-admin/src/model/client_keys.rs @@ -87,6 +87,7 @@ pub struct ClientKeyListQuery { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientKeyRecord { pub openai_client_profile_override: Option, + pub xai_client_profile_override: Option, pub id: ClientApiKeyId, pub name: String, pub label: Option, @@ -146,6 +147,7 @@ impl fmt::Debug for ClientKeySecret { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateClientKey { pub openai_client_profile_override: Option, + pub xai_client_profile_override: Option, pub custom_key: Option, pub name: String, pub label: Option, @@ -158,6 +160,7 @@ pub struct CreateClientKey { #[derive(Clone, PartialEq, Eq)] pub struct NewClientKey { pub openai_client_profile_override: Option, + pub xai_client_profile_override: Option, pub id: ClientApiKeyId, pub name: String, pub label: Option, @@ -183,6 +186,7 @@ impl fmt::Debug for NewClientKey { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateClientKey { pub openai_client_profile_override: Option>, + pub xai_client_profile_override: Option>, pub id: ClientApiKeyId, pub name: String, pub label: Option, diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 6c79a4340..e49bec263 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -18,6 +18,7 @@ pub use gateway_core::account::RotationStrategy; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeSettings { pub openai_client_profile: Option, + pub xai_client_profile: Option, pub config_revision: Revision, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, @@ -50,6 +51,7 @@ pub struct RuntimeSettings { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplaceRuntimeSettings { pub openai_client_profile: Option, + pub xai_client_profile: Option, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -126,3 +128,16 @@ impl fmt::Debug for RegeneratedAdminApiKey { .finish() } } + +impl RuntimeSettings { + pub fn client_profile( + &self, + provider: &str, + ) -> Option<&gateway_core::account::OpaqueProviderData> { + match provider { + "openai" => self.openai_client_profile.as_ref(), + "xai" => self.xai_client_profile.as_ref(), + _ => None, + } + } +} diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index eedb3943a..a8d03e5c0 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -57,7 +57,7 @@ pub trait ClientKeyService: Send + Sync { } pub(crate) struct DefaultClientKeyService { - profile_provider: Arc, + providers: crate::ports::provider::ProviderAdminRegistry, store: Arc, snapshot: Arc, } @@ -67,12 +67,12 @@ impl DefaultClientKeyService { pub(crate) fn new( store: Arc, snapshot: Arc, - profile_provider: Arc, + providers: crate::ports::provider::ProviderAdminRegistry, ) -> Self { Self { store, snapshot, - profile_provider, + providers, } } } @@ -113,10 +113,18 @@ impl ClientKeyService for DefaultClientKeyService { context: &MutationContext, command: CreateClientKey, ) -> Result { - if let Some(profile) = &command.openai_client_profile_override { - self.profile_provider - .preview_client_profile(profile) - .map_err(|error| super::map_provider_error(error, "client profile"))?; + for (provider, profile) in [ + ("openai", &command.openai_client_profile_override), + ("xai", &command.xai_client_profile_override), + ] { + if let Some(profile) = profile { + let kind = gateway_core::routing::ProviderKind::new(provider) + .map_err(|_| AdminError::invalid("Provider 不合法"))?; + self.providers + .require(&kind) + .and_then(|provider| provider.preview_client_profile(profile)) + .map_err(|error| super::map_provider_error(error, "client profile"))?; + } } let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple())) .map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?; @@ -132,6 +140,7 @@ impl ClientKeyService for DefaultClientKeyService { .create_client_key( NewClientKey { openai_client_profile_override: command.openai_client_profile_override, + xai_client_profile_override: command.xai_client_profile_override, id, name: command.name, label: command.label, @@ -156,10 +165,18 @@ impl ClientKeyService for DefaultClientKeyService { context: &MutationContext, command: UpdateClientKey, ) -> Result { - if let Some(Some(profile)) = &command.openai_client_profile_override { - self.profile_provider - .preview_client_profile(profile) - .map_err(|error| super::map_provider_error(error, "client profile"))?; + for (provider, profile) in [ + ("openai", &command.openai_client_profile_override), + ("xai", &command.xai_client_profile_override), + ] { + if let Some(Some(profile)) = profile { + let kind = gateway_core::routing::ProviderKind::new(provider) + .map_err(|_| AdminError::invalid("Provider 不合法"))?; + self.providers + .require(&kind) + .and_then(|provider| provider.preview_client_profile(profile)) + .map_err(|error| super::map_provider_error(error, "client profile"))?; + } } let id = command.id.clone(); let (config_revision, record) = self diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index e341ed59a..f79558bd1 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -199,6 +199,12 @@ impl DefaultObservabilityService { configuration.clone(), ); } + if let Some(configuration) = &settings.xai_client_profile { + configurations.insert( + gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"), + configuration.clone(), + ); + } let wire_profiles = self.providers.dashboard_wire_profiles(&configurations); let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account); let total_slots = runtime_slots.as_ref().map_or_else( diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index cf64bf2ec..a91c72d25 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -39,11 +39,13 @@ pub trait SettingsService: Send + Sync { ) -> Result<(), AdminError>; async fn client_profile_options( &self, + _provider: &str, ) -> Result { Err(AdminError::invalid("当前 Provider 不支持客户端身份配置")) } async fn preview_client_profile( &self, + _provider: &str, _configuration: Option<&gateway_core::account::OpaqueProviderData>, ) -> Result { Err(AdminError::invalid("当前 Provider 不支持客户端身份配置")) @@ -68,17 +70,26 @@ pub trait SettingsService: Send + Sync { pub(crate) struct DefaultSettingsService { providers: crate::ports::provider::ProviderAdminRegistry, pricing_source: Arc, - profile_provider: Arc, store: Arc, snapshot: Arc, } impl DefaultSettingsService { + fn profile_provider( + &self, + provider: &str, + ) -> Result, AdminError> { + let kind = gateway_core::routing::ProviderKind::new(provider) + .map_err(|_| AdminError::invalid("Provider 不合法"))?; + self.providers + .require(&kind) + .map_err(|error| super::map_provider_error(error, "client profile")) + } + #[must_use] pub(crate) fn new( store: Arc, snapshot: Arc, - profile_provider: Arc, providers: crate::ports::provider::ProviderAdminRegistry, pricing_source: Arc, ) -> Self { @@ -87,7 +98,6 @@ impl DefaultSettingsService { pricing_source, store, snapshot, - profile_provider, } } } @@ -250,16 +260,18 @@ impl SettingsService for DefaultSettingsService { async fn client_profile_options( &self, + provider: &str, ) -> Result { let mut options = self - .profile_provider + .profile_provider(provider)? .client_profile_options() .map_err(|error| super::map_provider_error(error, "client profile"))? .into_inner(); let configuration = self .load() .await? - .openai_client_profile + .client_profile(provider) + .cloned() .ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?; options.insert( "globalConfiguration".to_owned(), @@ -270,8 +282,10 @@ impl SettingsService for DefaultSettingsService { async fn preview_client_profile( &self, + provider: &str, configuration: Option<&gateway_core::account::OpaqueProviderData>, ) -> Result { + let profile_provider = self.profile_provider(provider)?; let global; let (configuration, source) = if let Some(configuration) = configuration { (configuration, "override") @@ -279,12 +293,12 @@ impl SettingsService for DefaultSettingsService { global = self .load() .await? - .openai_client_profile + .client_profile(provider) + .cloned() .ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?; (&global, "global") }; - let mut preview = self - .profile_provider + let mut preview = profile_provider .preview_client_profile(configuration) .map_err(|error| super::map_provider_error(error, "client profile"))? .into_inner(); @@ -305,10 +319,15 @@ impl SettingsService for DefaultSettingsService { command: ReplaceRuntimeSettings, ) -> Result { validate_settings(&command)?; - if let Some(profile) = &command.openai_client_profile { - self.profile_provider - .preview_client_profile(profile) - .map_err(|error| super::map_provider_error(error, "client profile"))?; + for (provider, profile) in [ + ("openai", &command.openai_client_profile), + ("xai", &command.xai_client_profile), + ] { + if let Some(profile) = profile { + self.profile_provider(provider)? + .preview_client_profile(profile) + .map_err(|error| super::map_provider_error(error, "client profile"))?; + } } let settings = self .store diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index ae35b7cad..bfc03b18c 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -991,6 +991,7 @@ impl SettingsStore for StaticSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), diff --git a/backend/crates/gateway-admin/tests/use_case/client_keys.rs b/backend/crates/gateway-admin/tests/use_case/client_keys.rs index 005c3c7b0..166c6f7c4 100644 --- a/backend/crates/gateway-admin/tests/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/tests/use_case/client_keys.rs @@ -75,6 +75,7 @@ impl ClientKeyStore for TestClientKeyStore { self.plaintexts.lock().unwrap().push(key.plaintext); let record = ClientKeyRecord { openai_client_profile_override: None, + xai_client_profile_override: None, id: key.id, name: key.name, label: key.label, @@ -260,6 +261,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat &mutation_context(), UpdateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id: ClientApiKeyId::new("key_existing").unwrap(), name: "Migration".to_owned(), label: None, @@ -280,6 +282,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat fn create_command(key: Option<&str>) -> CreateClientKey { CreateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()), name: "Migration".to_owned(), label: None, diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 8a1bbc7eb..7cafdc72a 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -26,6 +26,7 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings { RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 33cbbb26b..ea87fbbe4 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -773,6 +773,7 @@ impl SettingsStore for FixtureSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(1).expect("revision"), diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index c950f46e6..3a0b17581 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -13,6 +13,20 @@ use gateway_admin::{ struct UnusedSettingsStore; +#[tokio::test] +async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() { + let services = super::AdminHarness::new() + .settings(std::sync::Arc::new(UnusedSettingsStore)) + .build() + .await; + let error = services + .settings() + .preview_client_profile("unknown", None) + .await + .expect_err("unknown provider cannot use global settings"); + assert_eq!(error.kind(), AdminErrorKind::Invalid); +} + #[async_trait] impl SettingsStore for UnusedSettingsStore { async fn load_pricing(&self) -> AdminStoreResult { @@ -79,6 +93,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { }, ReplaceRuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), model_mappings: Default::default(), diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 3a332d0d0..941a131ef 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -148,6 +148,7 @@ impl ClientKeySort { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateClientKeyRequest { openai_client_profile_override: Option>, + xai_client_profile_override: Option>, custom_key: Option, name: String, label: Option, @@ -180,6 +181,9 @@ impl CreateClientKeyRequest { openai_client_profile_override: self .openai_client_profile_override .map(gateway_core::account::OpaqueProviderData::new), + xai_client_profile_override: self + .xai_client_profile_override + .map(gateway_core::account::OpaqueProviderData::new), custom_key: self .custom_key .filter(|key| !key.is_empty()) @@ -208,6 +212,8 @@ impl CreateClientKeyRequest { pub struct UpdateClientKeyRequest { #[serde(default, deserialize_with = "deserialize_profile_override")] openai_client_profile_override: Option>>, + #[serde(default, deserialize_with = "deserialize_profile_override")] + xai_client_profile_override: Option>>, id: String, name: String, label: Option, @@ -231,6 +237,9 @@ impl UpdateClientKeyRequest { openai_client_profile_override: self .openai_client_profile_override .map(|value| value.map(gateway_core::account::OpaqueProviderData::new)), + xai_client_profile_override: self + .xai_client_profile_override + .map(|value| value.map(gateway_core::account::OpaqueProviderData::new)), id: client_key_id(self.id, "clientKeyMutationNotFound")?, name: self.name, label: self.label, @@ -317,6 +326,7 @@ impl ClientKeyMutationRequest { #[serde(rename_all = "camelCase")] pub struct ClientKeyView { openai_client_profile_override: Option>, + xai_client_profile_override: Option>, id: String, name: String, label: Option, @@ -358,6 +368,9 @@ impl From for ClientKeyView { openai_client_profile_override: record .openai_client_profile_override .map(gateway_core::account::OpaqueProviderData::into_inner), + xai_client_profile_override: record + .xai_client_profile_override + .map(gateway_core::account::OpaqueProviderData::into_inner), id: record.id.to_string(), name: record.name, label: record.label, diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index a0060919f..da7071bdf 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt}; use axum::{ Router, - extract::State, + extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -35,6 +35,7 @@ pub type ModelMappings = BTreeMap; #[serde(rename_all = "camelCase")] pub struct RuntimeSettingsView { pub openai_client_profile: Option>, + pub xai_client_profile: Option>, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -68,6 +69,8 @@ pub struct RuntimeSettingsView { pub struct UpdateRuntimeSettingsRequest { #[serde(default, deserialize_with = "deserialize_profile_update")] pub openai_client_profile: Option>, + #[serde(default, deserialize_with = "deserialize_profile_update")] + pub xai_client_profile: Option>, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -181,6 +184,9 @@ impl UpdateRuntimeSettingsRequest { openai_client_profile: self .openai_client_profile .map(gateway_core::account::OpaqueProviderData::new), + xai_client_profile: self + .xai_client_profile + .map(gateway_core::account::OpaqueProviderData::new), request_location_enabled: self.request_location_enabled, request_location: self .request_location @@ -225,6 +231,9 @@ impl From for RuntimeSettingsView { openai_client_profile: settings .openai_client_profile .map(gateway_core::account::OpaqueProviderData::into_inner), + xai_client_profile: settings + .xai_client_profile + .map(gateway_core::account::OpaqueProviderData::into_inner), request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, model_mappings: wire_model_mappings(settings.model_mappings), @@ -361,11 +370,11 @@ where .route("/api/admin/settings/pricing/sync", post(sync_pricing::)) .route("/api/admin/settings", get(settings::)) .route( - "/api/admin/settings/client-profiles/openai", + "/api/admin/settings/client-profiles/{provider}", get(client_profile_options::), ) .route( - "/api/admin/settings/client-profiles/openai/preview", + "/api/admin/settings/client-profiles/{provider}/preview", post(preview_client_profile::), ) .route("/api/admin/settings/update", post(update_settings::)) @@ -731,6 +740,7 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>( async fn client_profile_options( _auth: AdminAuth, + Path(provider): Path, State(state): State, ) -> Result where @@ -739,7 +749,7 @@ where let result = state .admin_services() .settings() - .client_profile_options() + .client_profile_options(&provider) .await .map_err(map_service_error)?; Ok(AdminResponse::new( @@ -756,6 +766,7 @@ struct ClientProfilePreviewRequest { async fn preview_client_profile( _auth: AdminAuth, + Path(provider): Path, State(state): State, AdminJson(request): AdminJson, ) -> Result @@ -768,7 +779,7 @@ where let result = state .admin_services() .settings() - .preview_client_profile(configuration.as_ref()) + .preview_client_profile(&provider, configuration.as_ref()) .await .map_err(map_service_error)?; Ok(AdminResponse::new( diff --git a/backend/crates/gateway-api/tests/admin/client_keys.rs b/backend/crates/gateway-api/tests/admin/client_keys.rs index 8ea7aaf96..8b2e00da9 100644 --- a/backend/crates/gateway-api/tests/admin/client_keys.rs +++ b/backend/crates/gateway-api/tests/admin/client_keys.rs @@ -433,6 +433,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() { .expect("valid time"); let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord { openai_client_profile_override: None, + xai_client_profile_override: None, budget: Default::default(), id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"), name: "visible".to_owned(), @@ -586,3 +587,20 @@ fn profile_override_distinguishes_omission_from_explicit_inheritance() { json!({"client":"cli", "platform":"linux", "versionMode":"latest"}); assert!(decode(payload).unwrap().is_some()); } + +#[test] +fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() { + let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0}); + let decode = |body| { + serde_json::from_value::(body) + .unwrap() + .into_command() + .unwrap() + .xai_client_profile_override + }; + assert_eq!(decode(payload.clone()), None); + payload["xaiClientProfileOverride"] = json!(null); + assert_eq!(decode(payload.clone()), Some(None)); + payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"}); + assert!(decode(payload).unwrap().is_some()); +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 7f11b8493..ec71c8b74 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -521,6 +521,7 @@ impl SettingsStore for MemorySettingsStore { let mut settings = self.settings.lock().expect("settings"); let updated = RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: command.request_location_enabled, request_location: command.request_location, config_revision: next_revision(settings.config_revision), @@ -892,6 +893,7 @@ impl ClientKeyStore for MemoryClientKeyStore { Ok(Some(ClientKeySecret::new( ClientKeyRecord { openai_client_profile_override: None, + xai_client_profile_override: None, budget: Default::default(), id: id.clone(), name: "revealed".to_owned(), @@ -1435,6 +1437,7 @@ fn test_runtime_settings() -> RuntimeSettings { ]); RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 16fe7a94d..b392d73a8 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -103,6 +103,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { let settings = RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -148,6 +149,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { value, json!({ "openaiClientProfile": null, + "xaiClientProfile": null, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -202,6 +204,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .collect(); let settings = RuntimeSettings { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -251,6 +254,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .collect(); let mut expected_fields = request_fields; expected_fields.insert("openaiClientProfile".to_owned()); + expected_fields.insert("xaiClientProfile".to_owned()); expected_fields.insert("updatedAt".to_owned()); assert_eq!(response_fields, expected_fields); @@ -640,14 +644,15 @@ async fn settings_reject_removed_global_fast_policy() { #[test] fn global_profile_can_be_omitted_but_cannot_be_cleared() { - let mut body = update_body(); - body.as_object_mut().unwrap().remove("openaiClientProfile"); - assert!(serde_json::from_value::(body.clone()).is_ok()); - body["openaiClientProfile"] = json!(null); - assert!(serde_json::from_value::(body.clone()).is_err()); - body["openaiClientProfile"] = - json!({"client":"cli", "platform":"linux", "versionMode":"latest"}); - assert!(serde_json::from_value::(body).is_ok()); + for field in ["openaiClientProfile", "xaiClientProfile"] { + let mut body = update_body(); + body.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(body.clone()).is_ok()); + body[field] = json!(null); + assert!(serde_json::from_value::(body.clone()).is_err()); + body[field] = json!({"versionMode":"latest"}); + assert!(serde_json::from_value::(body).is_ok()); + } } fn custom_pricing() -> Value { diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index c02446ef5..ce0a6fd12 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -18,6 +18,7 @@ pub(super) async fn fixture() -> AdminTestFixture { let now = Utc::now(); *fixture.client_key.lock().unwrap() = Some(ClientKeyRecord { openai_client_profile_override: None, + xai_client_profile_override: None, id: ClientApiKeyId::new("key-42").unwrap(), name: "Development".to_owned(), label: Some("private-sentinel".to_owned()), diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 719c476eb..fa9a4264d 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -87,6 +87,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { let replacement = postgres::ControlPlaneReplacement { settings: postgres::RuntimeSettingsUpdate { openai_client_profile: command.openai_client_profile, + xai_client_profile: command.xai_client_profile, admin_api_key: current.settings.admin_api_key, refresh_margin_seconds: command.refresh_margin_seconds, refresh_concurrency: command.refresh_concurrency, @@ -232,6 +233,7 @@ pub(crate) fn admin_runtime_settings( .collect::>()?; Ok(AdminRuntimeSettings { openai_client_profile: settings.openai_client_profile, + xai_client_profile: settings.xai_client_profile, config_revision: admin_revision(settings.config_revision)?, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 785fef27d..677028789 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -112,6 +112,7 @@ impl fmt::Debug for ClientApiKeySecret { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientApiKeyRecord { pub openai_client_profile_override: Option, + pub xai_client_profile_override: Option, pub id: String, pub name: String, pub label: Option, @@ -273,6 +274,7 @@ pub struct ClientApiKeyPage { #[derive(Clone)] pub struct NewClientApiKey { pub openai_client_profile_override: Option, + pub xai_client_profile_override: Option, pub id: String, pub name: String, pub label: Option, @@ -306,6 +308,7 @@ impl NewClientApiKey { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateClientApiKeyDetails { pub openai_client_profile_override: Option>, + pub xai_client_profile_override: Option>, pub id: String, pub name: String, pub label: Option, @@ -363,6 +366,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?; let mut statement = QueryBuilder::::new( "select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override, + k.provider_request_profiles_json -> 'xai' as xai_client_profile_override, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds @@ -423,6 +427,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { require_nonempty(ENTITY, "id", id)?; let record = sqlx::query( "select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override, + k.provider_request_profiles_json -> 'xai' as xai_client_profile_override, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups, @@ -769,6 +774,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .create_client_api_key( NewClientApiKey { openai_client_profile_override: command.openai_client_profile_override, + xai_client_profile_override: command.xai_client_profile_override, id: id.as_str().to_owned(), name: command.name, label: command.label, @@ -820,6 +826,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .update_client_api_key( UpdateClientApiKeyDetails { openai_client_profile_override: command.openai_client_profile_override, + xai_client_profile_override: command.xai_client_profile_override, id: id.as_str().to_owned(), name: command.name, label: command.label, @@ -978,6 +985,7 @@ fn admin_client_key_cursor(cursor: ClientApiKeyCursor) -> AdminStoreResult AdminStoreResult { Ok(AdminClientKeyRecord { openai_client_profile_override: record.openai_client_profile_override, + xai_client_profile_override: record.xai_client_profile_override, id: ClientApiKeyId::new(record.id) .map_err(|_| admin_store_error(ENTITY, invalid("invalid client key id")))?, name: record.name, @@ -1029,7 +1037,8 @@ pub(crate) async fn insert_client_api_key_in_transaction( "insert into client_api_keys ( id, name, label, key, enabled, max_concurrency, requests_per_minute, last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json - ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end)", + ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end + || case when $10::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $10::jsonb) end)", ) .bind(&key.id) .bind(key.name.trim()) @@ -1040,6 +1049,7 @@ pub(crate) async fn insert_client_api_key_in_transaction( .bind(key.budget.daily_usd.canonical()) .bind(key.budget.weekly_usd.canonical()) .bind(key.openai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) + .bind(key.xai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) .execute(&mut **transaction) .await .map_err(|error| { @@ -1072,9 +1082,11 @@ pub(crate) async fn update_client_api_key_in_transaction( requests_per_minute = $5, updated_at = now(), daily_limit_usd = coalesce($6::text::numeric, daily_limit_usd), weekly_limit_usd = coalesce($7::text::numeric, weekly_limit_usd), - provider_request_profiles_json = case when not $8 then provider_request_profiles_json - when $9::jsonb is null then provider_request_profiles_json - 'openai' - else jsonb_set(provider_request_profiles_json, '{openai}', $9) end + provider_request_profiles_json = (provider_request_profiles_json + - case when $8 then array['openai'] else array[]::text[] end + - case when $10 then array['xai'] else array[]::text[] end) + || case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end + || case when $11::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $11::jsonb) end where id = $1", ) .bind(&key.id) @@ -1091,6 +1103,13 @@ pub(crate) async fn update_client_api_key_in_transaction( .and_then(Option::as_ref) .map(|profile| sqlx::types::Json(profile.expose_to_provider())), ) + .bind(key.xai_client_profile_override.is_some()) + .bind( + key.xai_client_profile_override + .as_ref() + .and_then(Option::as_ref) + .map(|profile| sqlx::types::Json(profile.expose_to_provider())), + ) .execute(&mut **transaction) .await .map_err(|_| postgres_unavailable("update client API key in transaction"))?; @@ -1246,6 +1265,12 @@ fn client_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult>>, _>( + "xai_client_profile_override", + ) + .map_err(|_| invalid("invalid request profile"))? + .map(|value| gateway_core::account::OpaqueProviderData::new(value.0)), id: row.try_get("id").map_err(|_| invalid("invalid id"))?, name: row.try_get("name").map_err(|_| invalid("invalid name"))?, label: row.try_get("label").map_err(|_| invalid("invalid label"))?, diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index de705ca9d..1fbcd4f45 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -20,6 +20,7 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable}; #[derive(Clone, PartialEq, Eq)] pub struct RuntimeSettings { pub openai_client_profile: Option, + pub xai_client_profile: Option, pub config_revision: Revision, pub admin_api_key: Option, pub refresh_margin_seconds: u64, @@ -110,6 +111,7 @@ impl fmt::Debug for RuntimeSettings { #[derive(Clone)] pub struct RuntimeSettingsUpdate { pub openai_client_profile: Option, + pub xai_client_profile: Option, pub admin_api_key: Option, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, @@ -373,7 +375,9 @@ pub(crate) async fn update_runtime_settings_in_transaction( request_location_json = $23, request_location_enabled = $24, responses_max_decompressed_body_bytes = $25, - provider_request_profiles_json = case when $26::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $26) end, + provider_request_profiles_json = provider_request_profiles_json + || case when $26::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $26::jsonb) end + || case when $27::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $27::jsonb) end, updated_at = now() where id = 1 returning config_revision", @@ -416,6 +420,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( .map_err(|_| invalid_numeric())?, ) .bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) + .bind(update.xai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) .fetch_optional(&mut **transaction) .await .map_err(|_| postgres_unavailable("update runtime settings in transaction"))? @@ -504,6 +509,11 @@ fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult) -> NewClientKey { let marker = char::from(id.as_bytes().last().copied().unwrap_or(b'k')); NewClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, budget: Default::default(), id: client_key_id(id), name: id.to_owned(), diff --git a/backend/crates/gateway-store/tests/postgres/client_budgets.rs b/backend/crates/gateway-store/tests/postgres/client_budgets.rs index 9da22d31e..adbcb3151 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budgets.rs @@ -472,6 +472,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { .unwrap(); let update = UpdateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id: ClientApiKeyId::new("key").unwrap(), name: "key".to_owned(), label: None, @@ -491,6 +492,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { .update_client_key( UpdateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, daily_limit_usd: None, weekly_limit_usd: None, ..update.clone() @@ -515,6 +517,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { .update_client_key( UpdateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, daily_limit_usd: Some("0".parse().unwrap()), weekly_limit_usd: None, ..update diff --git a/backend/crates/gateway-store/tests/postgres/client_keys.rs b/backend/crates/gateway-store/tests/postgres/client_keys.rs index 530d7de9e..7ccfa4b85 100644 --- a/backend/crates/gateway-store/tests/postgres/client_keys.rs +++ b/backend/crates/gateway-store/tests/postgres/client_keys.rs @@ -50,6 +50,7 @@ async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() { .create_client_key( NewClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id: id.clone(), name: format!("Migrated {index}"), label: None, @@ -105,6 +106,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() { let key = "legacy-key-case-sensitive!"; let command = |id: &str| NewClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id: ClientApiKeyId::new(id).unwrap(), name: id.to_owned(), label: None, @@ -141,6 +143,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() { fn generated_client_key_format_remains_valid() { let key = NewClientApiKey { openai_client_profile_override: None, + xai_client_profile_override: None, budget: Default::default(), id: "key-1".to_owned(), name: "default".to_owned(), @@ -174,6 +177,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { }; let create = |id: &str, name: &str| NewClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id: ClientApiKeyId::new(id).unwrap(), name: name.to_owned(), label: None, @@ -184,6 +188,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { }; let update = |id: ClientApiKeyId, name: &str| UpdateClientKey { openai_client_profile_override: None, + xai_client_profile_override: None, id, name: name.to_owned(), label: None, @@ -639,6 +644,7 @@ fn client_key_debug_redacts_plaintext() { let secret = format!("sk_{}", "s".repeat(43)); let key = NewClientApiKey { openai_client_profile_override: None, + xai_client_profile_override: None, budget: Default::default(), id: "key-1".to_owned(), name: "default".to_owned(), @@ -717,12 +723,14 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance budget: Default::default(), plaintext: "synthetic-profile-test-key".to_owned(), openai_client_profile_override: Some(profile.clone()), + xai_client_profile_override: Some(profile.clone()), }, &context, ) .await .unwrap(); assert_eq!(record.openai_client_profile_override, Some(profile.clone())); + assert_eq!(record.xai_client_profile_override, Some(profile.clone())); let update = |override_value| UpdateClientKey { id: id.clone(), name: "profile".to_owned(), @@ -732,6 +740,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance daily_limit_usd: None, weekly_limit_usd: None, openai_client_profile_override: override_value, + xai_client_profile_override: None, }; let (_, unchanged) = store .update_client_key(update(None), &context) @@ -741,6 +750,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance unchanged.openai_client_profile_override, Some(profile.clone()) ); + assert_eq!(unchanged.xai_client_profile_override, Some(profile.clone())); let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) .load_runtime_snapshot() .await @@ -749,6 +759,20 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance snapshot.client_api_keys[0].request_profiles.values().next(), Some(&profile) ); + assert_eq!( + snapshot.client_api_keys[0] + .request_profiles + .get(&gateway_core::routing::ProviderKind::new("xai").unwrap()), + Some(&profile) + ); + let mut clear_xai = update(None); + clear_xai.xai_client_profile_override = Some(None); + let (_, cleared_xai) = store.update_client_key(clear_xai, &context).await.unwrap(); + assert_eq!(cleared_xai.xai_client_profile_override, None); + assert_eq!( + cleared_xai.openai_client_profile_override, + Some(profile.clone()) + ); let (_, cleared) = store .update_client_key(update(Some(None)), &context) .await diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 1c1686bd9..8ad2a09b4 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -10,6 +10,7 @@ use super::TestDatabase; fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { RuntimeSettingsUpdate { openai_client_profile: None, + xai_client_profile: None, request_location_enabled: false, request_location: Default::default(), admin_api_key: None, @@ -502,3 +503,53 @@ async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_i ); database.close().await; } + +#[tokio::test] +async fn xai_profile_initialization_and_updates_preserve_other_providers() { + use gateway_core::{ + account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort, + routing::ProviderKind, + }; + let Some(database) = TestDatabase::create("xai_profiles").await else { + return; + }; + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let document = |version: &str| { + OpaqueProviderData::new( + serde_json::json!({"clientVersion":version}) + .as_object() + .unwrap() + .clone(), + ) + }; + let provider = ProviderKind::new("xai").unwrap(); + repository + .initialize_request_profile(&provider, document("initial")) + .await + .unwrap(); + assert_eq!( + repository + .initialize_request_profile(&provider, document("ignored")) + .await + .unwrap(), + document("initial") + ); + let mut update = settings_with_margin(3600); + update.openai_client_profile = Some(document("openai")); + update.xai_client_profile = Some(document("xai")); + repository.update_runtime_settings(update).await.unwrap(); + let mut update = settings_with_margin(3600); + update.xai_client_profile = Some(document("edited")); + repository.update_runtime_settings(update).await.unwrap(); + let settings = repository.load_runtime_settings().await.unwrap(); + assert_eq!(settings.openai_client_profile, Some(document("openai"))); + assert_eq!(settings.xai_client_profile, Some(document("edited"))); + assert_eq!( + repository + .initialize_request_profile(&provider, document("old-yaml")) + .await + .unwrap(), + document("edited") + ); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index a15e9472b..36a0f15e4 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -1,14 +1,13 @@ -//! OpenAI Provider 启动配置与 Codex Desktop 请求画像校验。 +//! OpenAI Provider 启动配置;客户端身份由管理端设置持久化。 use std::path::{Path, PathBuf}; use std::time::Duration; -use chrono::{DateTime, Utc}; use serde::Deserialize; use url::Url; use crate::credential::CodexQuotaRefreshPolicy; -use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState}; +use crate::transport::profile::CodexResidency; use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError}; use crate::transport::websocket::CodexWebSocketPoolConfig; use crate::{ @@ -41,7 +40,7 @@ pub struct OpenAiConfig { #[serde(default = "default_stream_max_retries")] pub stream_max_retries: u64, #[serde(default)] - pub wire_profile: CodexWireProfileConfig, + pub residency: Option, #[serde(skip)] identity_secret_path: PathBuf, } @@ -56,69 +55,10 @@ impl OpenAiConfig { self.ws_pool.validate()?; self.quota.validate()?; self.auth.validate()?; - self.wire_profile.validate()?; self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret"); Ok(()) } - #[must_use] - pub fn wire_profile_state(&self) -> CodexWireProfileState { - // 官方发布基线独立于用户固定配置;旧 YAML 只用于首次导入管理设置。 - CodexWireProfileState::new( - CodexWireProfileConfig { - residency: self.wire_profile.residency, - ..CodexWireProfileConfig::default() - } - .into(), - ) - } - - /// 将旧部署选择转换为一次性初始化值;数据库已有选择时不覆盖。 - pub fn initial_client_profile( - &self, - ) -> Result { - use crate::transport::profile::selection::{ - ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, - }; - let profile = &self.wire_profile; - let baseline = CodexWireProfileConfig::default(); - let platform = match profile.os_type.as_str() { - "Mac OS" | "Darwin" | "macOS" => ClientPlatform::Macos, - "Linux" => ClientPlatform::Linux, - "Windows" => ClientPlatform::Windows, - _ => { - return Err(OpenAiConfigError::InvalidField( - "openai.wire_profile.os_type", - )); - } - }; - let fixed = profile.codex_version != baseline.codex_version - || profile.desktop_version != baseline.desktop_version - || profile.desktop_build != baseline.desktop_build - || platform != ClientPlatform::Macos - || profile.arch != "arm64"; - let selection = ClientProfileSelection { - client: ClientKind::Desktop, - platform, - version_mode: if fixed { - VersionMode::Fixed - } else { - VersionMode::Latest - }, - originator: (profile.originator != baseline.originator) - .then(|| profile.originator.clone()), - os_version: Some(profile.os_version.clone()), - arch: Some(profile.arch.clone()), - terminal: Some(profile.terminal.clone()), - codex_version: fixed.then(|| profile.codex_version.clone()), - desktop_version: fixed.then(|| profile.desktop_version.clone()), - desktop_build: fixed.then(|| profile.desktop_build.clone()), - }; - selection - .document() - .map_err(|_| OpenAiConfigError::InvalidField("openai.wire_profile")) - } - #[must_use] pub fn base_url(&self) -> &str { &self.api.base_url @@ -174,7 +114,7 @@ impl Default for OpenAiConfig { quota: CodexQuotaSettings::default(), auth: CodexAuthSettings::default(), stream_max_retries: DEFAULT_STREAM_MAX_RETRIES, - wire_profile: CodexWireProfileConfig::default(), + residency: None, identity_secret_path: PathBuf::new(), } } @@ -307,115 +247,8 @@ impl CodexAuthSettings { } } -/// 经审计固定的 Codex Desktop 上游请求画像。 -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(default)] -pub struct CodexWireProfileConfig { - pub originator: String, - /// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。 - pub codex_version: String, - pub desktop_version: String, - pub desktop_build: String, - pub os_type: String, - pub os_version: String, - pub arch: String, - pub terminal: String, - #[serde(default)] - pub residency: Option, - pub verified_at: DateTime, -} - -impl Default for CodexWireProfileConfig { - fn default() -> Self { - Self { - originator: "Codex Desktop".to_owned(), - codex_version: "0.153.4".to_owned(), - desktop_version: "26.901.51231".to_owned(), - desktop_build: "8109".to_owned(), - os_type: "Mac OS".to_owned(), - os_version: "15.7.1".to_owned(), - arch: "arm64".to_owned(), - terminal: "unknown".to_owned(), - residency: None, - // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 - verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), - } - } -} - -impl CodexWireProfileConfig { - fn validate(&self) -> Result<(), OpenAiConfigError> { - for (field, value) in [ - ("openai.wire_profile.originator", self.originator.as_str()), - ( - "openai.wire_profile.codex_version", - self.codex_version.as_str(), - ), - ( - "openai.wire_profile.desktop_version", - self.desktop_version.as_str(), - ), - ( - "openai.wire_profile.desktop_build", - self.desktop_build.as_str(), - ), - ("openai.wire_profile.os_type", self.os_type.as_str()), - ("openai.wire_profile.os_version", self.os_version.as_str()), - ("openai.wire_profile.arch", self.arch.as_str()), - ("openai.wire_profile.terminal", self.terminal.as_str()), - ] { - if value.trim().is_empty() { - return Err(OpenAiConfigError::InvalidField(field)); - } - } - if semver::Version::parse(&self.codex_version).is_err() { - return Err(OpenAiConfigError::InvalidField( - "openai.wire_profile.codex_version", - )); - } - if !numeric_dotted_version(&self.desktop_version) { - return Err(OpenAiConfigError::InvalidField( - "openai.wire_profile.desktop_version", - )); - } - if !self.desktop_build.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(OpenAiConfigError::InvalidField( - "openai.wire_profile.desktop_build", - )); - } - Ok(()) - } -} - -impl From for CodexWireProfile { - fn from(value: CodexWireProfileConfig) -> Self { - Self { - client_kind: crate::transport::profile::selection::ClientKind::Desktop, - originator: value.originator, - codex_version: value.codex_version, - desktop_version: value.desktop_version, - desktop_build: value.desktop_build, - os_type: value.os_type, - os_version: value.os_version, - arch: value.arch, - terminal: value.terminal, - residency: value.residency, - verified_at: value.verified_at, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum OpenAiConfigError { #[error("OpenAI configuration field is invalid: {0}")] InvalidField(&'static str), } - -fn numeric_dotted_version(value: &str) -> bool { - let mut parts = value.split('.'); - let valid_parts = parts - .by_ref() - .filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) - .count(); - valid_parts >= 2 && valid_parts == value.split('.').count() -} diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 0f7e88a47..9bb64b314 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -27,7 +27,7 @@ use crate::transport::profile::{ }; use crate::transport::{CodexWebSocketPool, build_reqwest_client}; -pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError}; +pub use config::{OpenAiConfig, OpenAiConfigError}; pub use provider::{ CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH, OFFICIAL_CODEX_BASE_URL, openai_failure_affects_account_score, @@ -62,9 +62,9 @@ pub async fn initialize( let session_exclusions = ports.session_exclusions(); let account_feedback = ports.account_feedback(); let runtime_policy = ports.runtime_policy(); - let initial_profile = config - .initial_client_profile() - .map_err(OpenAiInitializeError::Config)?; + let initial_profile = transport::profile::selection::ClientProfileSelection::default() + .document() + .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; let configured_profile = runtime_policy .initialize_request_profile(&provider_kind, initial_profile) .await @@ -72,7 +72,11 @@ pub async fn initialize( transport::profile::selection::ClientProfileSelection::parse(&configured_profile) .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; let credential_state = ports.credential_state(); - let profile = config.wire_profile_state(); + let profile = + transport::profile::CodexWireProfileState::new(transport::profile::CodexWireProfile { + residency: config.residency, + ..Default::default() + }); let artifact_cache = CodexArtifactProfileCache::new(provider_kind.clone(), ports.artifact_profiles()); let configured_build = profile.snapshot().desktop_build.parse::().ok(); @@ -269,8 +273,6 @@ impl ProviderBundle { /// OpenAI 初始化失败的脱敏分类。 #[derive(Debug, thiserror::Error)] pub enum OpenAiInitializeError { - #[error(transparent)] - Config(OpenAiConfigError), #[error("OpenAI runtime policy is unavailable")] RuntimePolicy, #[error("OpenAI Provider kind is invalid")] diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index cbb200b95..7d828274e 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -50,7 +50,7 @@ pub use gateway_core::account::RequestLocation as CodexRequestLocation; /// Codex Desktop 上游请求身份。 /// -/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用 +/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用 /// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexWireProfile { @@ -77,6 +77,25 @@ pub struct CodexWireProfile { pub verified_at: DateTime, } +impl Default for CodexWireProfile { + fn default() -> Self { + Self { + client_kind: ClientKind::Desktop, + originator: "Codex Desktop".to_owned(), + codex_version: "0.153.4".to_owned(), + desktop_version: "26.901.51231".to_owned(), + desktop_build: "8109".to_owned(), + os_type: "Mac OS".to_owned(), + os_version: "15.7.1".to_owned(), + arch: "arm64".to_owned(), + terminal: "unknown".to_owned(), + residency: None, + // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 + verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), + } + } +} + impl CodexWireProfile { /// 按 bundled Core app-server 的官方格式生成最终 User-Agent。 pub fn user_agent(&self) -> String { diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index f17a6d1f7..134850547 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -44,7 +44,7 @@ use gateway_core::routing::{ RuntimeSnapshot, UpstreamModelId, }; use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable}; -use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig}; +use provider_openai::config::OpenAiConfig; use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential}; use provider_openai::transport::profile::APPCAST_POLL_INTERVAL; use secrecy::SecretString; @@ -321,9 +321,14 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() baseline.release.as_ref().map(|release| release.status), Some(DesktopReleaseStatus::Unchecked) ); + let selection = OpaqueProviderData::new(json!({ + "client": "desktop", "platform": "macos", "versionMode": "fixed", + "codexVersion": "0.102.0", "desktopVersion": "1.2026.190", "desktopBuild": "19012345678", + "osVersion": "15.5.0", "arch": "arm64", "terminal": "xterm-256color" + }).as_object().unwrap().clone()); let profile = admin - .configured_wire_profile(&config.config.initial_client_profile().unwrap()) - .expect("imported fixed profile"); + .configured_wire_profile(&selection) + .expect("managed fixed profile"); assert_eq!(profile.version, "0.102.0"); assert_eq!(profile.build, None); assert_eq!(profile.target.os_type, "Mac OS"); @@ -1416,21 +1421,6 @@ struct TestOpenAiConfig { fn valid_config() -> TestOpenAiConfig { let mut config = OpenAiConfig::default(); - config.wire_profile = CodexWireProfileConfig { - originator: "Codex Desktop".to_owned(), - codex_version: "0.102.0".to_owned(), - desktop_version: "1.2026.190".to_owned(), - desktop_build: "19012345678".to_owned(), - os_type: "Mac OS".to_owned(), - os_version: "15.5.0".to_owned(), - arch: "arm64".to_owned(), - terminal: "xterm-256color".to_owned(), - residency: None, - verified_at: Utc - .with_ymd_and_hms(2026, 7, 19, 0, 0, 0) - .single() - .expect("valid test time"), - }; let runtime = tempfile::tempdir().expect("test runtime directory"); config .resolve_and_validate(&runtime.path().join("deploy")) diff --git a/backend/crates/providers/openai/tests/config.rs b/backend/crates/providers/openai/tests/config.rs index 0d42a251f..9b59a1bda 100644 --- a/backend/crates/providers/openai/tests/config.rs +++ b/backend/crates/providers/openai/tests/config.rs @@ -1,32 +1,21 @@ use std::path::Path; -use chrono::{TimeZone as _, Utc}; -use provider_openai::config::{ - CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig, -}; +use provider_openai::config::{DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig}; #[test] -fn openai_config_imports_legacy_fixed_profile_without_changing_official_release_facts() { - let mut config = valid_config(); - config - .resolve_and_validate(Path::new("/srv/gateway")) - .expect("valid OpenAI config"); - let state = config.wire_profile_state(); - let selection = provider_openai::transport::profile::selection::ClientProfileSelection::parse( - &config.initial_client_profile().unwrap(), - ) - .unwrap(); - let profile = selection.resolve(&state).unwrap(); - assert_eq!( - state.snapshot().codex_version, - CodexWireProfileConfig::default().codex_version - ); +fn openai_config_ignores_removed_yaml_identity_fields() { + let config: OpenAiConfig = serde_json::from_value(serde_json::json!({ + "wire_profile": {"originator": "legacy-client", "codex_version": "invalid", "residency": "us"} + })).unwrap(); + assert_eq!(config, OpenAiConfig::default()); +} - 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.desktop_build, "19012345678"); +#[test] +fn openai_config_keeps_residency_separate_from_client_identity() { + use provider_openai::transport::profile::CodexResidency; + let config: OpenAiConfig = serde_json::from_str(r#"{"residency":"us"}"#).unwrap(); + assert_eq!(config.residency, Some(CodexResidency::Us)); + assert!(serde_json::from_str::(r#"{"residency":"invalid"}"#).is_err()); } #[test] @@ -39,33 +28,6 @@ fn openai_config_derives_identity_secret_from_runtime_data_dir() { assert!(format!("{config:?}").contains("/srv/gateway/runtime-data/identity_hmac_secret")); } -#[test] -fn openai_config_rejects_noncanonical_versions_and_empty_fields() { - let mut config = valid_config(); - config.wire_profile.codex_version = "latest".to_owned(); - assert!( - config - .resolve_and_validate(Path::new("/srv/gateway")) - .is_err() - ); - - let mut config = valid_config(); - config.wire_profile.desktop_version = "1.preview".to_owned(); - assert!( - config - .resolve_and_validate(Path::new("/srv/gateway")) - .is_err() - ); - - let mut config = valid_config(); - config.wire_profile.originator.clear(); - assert!( - config - .resolve_and_validate(Path::new("/srv/gateway")) - .is_err() - ); -} - #[test] fn openai_config_restricts_upstream_base_url_to_https_or_loopback_http() { for base_url in [ @@ -135,7 +97,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() { ) ); assert_eq!( - config.wire_profile_state().snapshot().user_agent(), + provider_openai::transport::profile::CodexWireProfile::default().user_agent(), "Codex Desktop/0.153.4 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.901.51231)" ); } @@ -152,21 +114,5 @@ fn openai_stream_retry_budget_uses_the_official_hard_cap() { } fn valid_config() -> OpenAiConfig { - let mut config = OpenAiConfig::default(); - config.wire_profile = CodexWireProfileConfig { - originator: "Codex Desktop".to_owned(), - codex_version: "0.102.0".to_owned(), - desktop_version: "1.2026.190".to_owned(), - desktop_build: "19012345678".to_owned(), - os_type: "Mac OS".to_owned(), - os_version: "15.5.0".to_owned(), - arch: "arm64".to_owned(), - terminal: "xterm-256color".to_owned(), - residency: None, - verified_at: Utc - .with_ymd_and_hms(2026, 7, 19, 0, 0, 0) - .single() - .expect("valid test time"), - }; - config + OpenAiConfig::default() } diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 57e5ab6bd..04aff4871 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -277,7 +277,7 @@ fn pat_service(server: &MockServer) -> CodexCredentialAdminService { client_id: "test-public-client".to_owned(), token_endpoint: format!("{}/oauth/token", server.uri()), }, - provider_openai::OpenAiConfig::default().wire_profile_state(), + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()), ) .expect("auth client"); CodexCredentialAdminService::new( @@ -389,7 +389,7 @@ async fn pat_import_times_out_without_falling_back_to_document_identity() { client_id: "test-public-client".to_owned(), token_endpoint: format!("{}/oauth/token", server.uri()), }, - provider_openai::OpenAiConfig::default().wire_profile_state(), + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()), ); let service = CodexCredentialAdminService::new( Arc::new(UnusedRefresher), diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 40dc9d5b8..5fe555ccc 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -10,7 +10,6 @@ use gateway_admin::model::provider_credentials::{ use gateway_admin::model::{MutationActor, MutationContext}; use gateway_core::account::{AccountStatus, ProviderAccountId}; use gateway_core::routing::ProviderKind; -use provider_openai::OpenAiConfig; use provider_openai::credential::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, AuthorizationTokenSet, @@ -362,7 +361,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() { } fn profile() -> CodexWireProfileState { - OpenAiConfig::default().wire_profile_state() + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()) } #[tokio::test] diff --git a/backend/crates/providers/openai/tests/credential/refresh.rs b/backend/crates/providers/openai/tests/credential/refresh.rs index e754b3f60..46a07804a 100644 --- a/backend/crates/providers/openai/tests/credential/refresh.rs +++ b/backend/crates/providers/openai/tests/credential/refresh.rs @@ -431,7 +431,7 @@ async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window client_id: "test-public-client".to_owned(), token_endpoint: format!("{}/oauth/token", server.uri()), }, - provider_openai::OpenAiConfig::default().wire_profile_state(), + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()), )), Arc::new(RefreshLeases), Arc::new(RefreshCredentialState), diff --git a/backend/crates/providers/openai/tests/credential/token_client.rs b/backend/crates/providers/openai/tests/credential/token_client.rs index 673eb4775..86119bca2 100644 --- a/backend/crates/providers/openai/tests/credential/token_client.rs +++ b/backend/crates/providers/openai/tests/credential/token_client.rs @@ -57,7 +57,8 @@ async fn refresh_tracks_the_online_profile_without_contaminating_code_exchange() .expect(4) .mount(&server) .await; - let profile = provider_openai::OpenAiConfig::default().wire_profile_state(); + let profile = + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()); let client = provider_openai::credential::token_client::openai_token_client( TokenClientConfig { client_id: "audit-client".to_owned(), @@ -123,7 +124,7 @@ fn client(server: &MockServer) -> OpenAiTokenClient { client_id: "test-public-client".to_owned(), token_endpoint: format!("{}/oauth/token", server.uri()), }, - provider_openai::OpenAiConfig::default().wire_profile_state(), + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()), ) } diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index 503127c0e..bfd4c5a0a 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -735,7 +735,7 @@ async fn concurrent_request_profiles_emit_independent_http_identities() { } requests }); - let state = provider_openai::config::OpenAiConfig::default().wire_profile_state(); + let state = provider_openai::transport::profile::CodexWireProfileState::new(Default::default()); let desktop = ClientProfileSelection::default().resolve(&state).unwrap(); let cli = ClientProfileSelection { client: ClientKind::Cli, diff --git a/backend/crates/providers/openai/tests/transport/profile/selection.rs b/backend/crates/providers/openai/tests/transport/profile/selection.rs index 305be1d11..7635e68fc 100644 --- a/backend/crates/providers/openai/tests/transport/profile/selection.rs +++ b/backend/crates/providers/openai/tests/transport/profile/selection.rs @@ -96,6 +96,9 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() { json!({"codexVersion":"0.155.0"}), json!({"desktopVersion":"26.1.0"}), json!({"versionMode":"fixed"}), + json!({"versionMode":"fixed", "codexVersion":"latest"}), + json!({"client":"desktop", "versionMode":"fixed", "codexVersion":"0.153.4", "desktopVersion":"1.preview", "desktopBuild":"8109"}), + json!({"client":"desktop", "versionMode":"fixed", "codexVersion":"0.153.4", "desktopVersion":"26.901.51231", "desktopBuild":"build"}), json!({"extraHeader":"secret"}), ] { let mut fields = baseline.as_object().unwrap().clone(); diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 8e76ddf86..19ba98cbd 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -321,6 +321,60 @@ impl XaiAdminProvider { #[async_trait] impl ProviderAdmin for XaiAdminProvider { + fn client_profile_options( + &self, + ) -> Result { + crate::transport::client_profile::object(&serde_json::json!({ + "defaults": crate::transport::client_profile::GrokClientProfileSelection::default(), + })) + .map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid)) + } + + fn preview_client_profile( + &self, + configuration: &gateway_core::account::OpaqueProviderData, + ) -> Result { + crate::transport::client_profile::GrokClientProfileSelection::parse(configuration) + .and_then(|selection| { + selection.preview(&self.wire_profile, &self.cli_release.snapshot()) + }) + .map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid)) + } + + fn configured_wire_profile( + &self, + configuration: &gateway_core::account::OpaqueProviderData, + ) -> Option { + use crate::transport::client_profile::{GrokClientProfileSelection, VersionMode}; + let selection = GrokClientProfileSelection::parse(configuration).ok()?; + let profile = selection.resolve(&self.wire_profile).ok()?; + let mut view = self.dashboard_wire_profile()?; + view.user_agent = profile.user_agent(); + view.version = profile.client_version; + view.target.os_type = profile.target_os; + view.target.arch = profile.target_arch; + view.target.terminal = profile.client_mode.clone(); + view.attributes = vec![ + DashboardWireAttribute { + label: "客户端标识".to_owned(), + value: profile.client_identifier, + }, + DashboardWireAttribute { + label: "运行模式".to_owned(), + value: profile.client_mode, + }, + DashboardWireAttribute { + label: "Token 认证".to_owned(), + value: "xai-grok-cli".to_owned(), + }, + ]; + if selection.version_mode == VersionMode::Fixed { + view.release = None; + view.verified_at = None; + } + Some(view) + } + fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { crate::transport::canonical::pricing_catalog() } @@ -359,13 +413,7 @@ impl ProviderAdmin for XaiAdminProvider { arch: profile.target_arch.clone(), terminal: profile.client_mode.clone(), }, - user_agent: format!( - "{}/{} ({}; {})", - profile.client_identifier, - profile.client_version, - profile.target_os, - profile.target_arch - ), + user_agent: profile.user_agent(), attributes: vec![ DashboardWireAttribute { label: "客户端标识".to_owned(), diff --git a/backend/crates/providers/xai/src/config.rs b/backend/crates/providers/xai/src/config.rs deleted file mode 100644 index e54c8c036..000000000 --- a/backend/crates/providers/xai/src/config.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! xAI Provider 启动配置与 Grok CLI 请求画像校验。 - -use std::path::Path; - -use chrono::{DateTime, Utc}; -use serde::Deserialize; - -use crate::{GrokOAuthConfig, XaiWireProfileState}; - -/// xAI Provider 唯一启动配置。 -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct XaiConfig { - pub wire_profile: XaiWireProfileConfig, -} - -impl XaiConfig { - /// 校验 Provider-owned 字段;当前配置不含文件系统路径。 - pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), XaiConfigError> { - self.wire_profile.validate()?; - self.oauth_config().map(|_| ()) - } - - #[must_use] - pub fn wire_profile_state(&self) -> XaiWireProfileState { - XaiWireProfileState::new(self.wire_profile.clone()) - } - - pub(crate) fn oauth_config(&self) -> Result { - GrokOAuthConfig::official().map_err(|_| XaiConfigError::InvalidProtocol) - } -} - -/// 经参考实现核验的 Grok CLI 请求画像。 -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct XaiWireProfileConfig { - pub client_identifier: String, - pub client_version: String, - pub client_mode: String, - pub target_os: String, - pub target_arch: String, - pub verified_at: DateTime, -} - -impl XaiWireProfileConfig { - fn validate(&self) -> Result<(), XaiConfigError> { - for (field, value) in [ - ( - "xai.wire_profile.client_identifier", - self.client_identifier.as_str(), - ), - ( - "xai.wire_profile.client_version", - self.client_version.as_str(), - ), - ("xai.wire_profile.client_mode", self.client_mode.as_str()), - ("xai.wire_profile.target_os", self.target_os.as_str()), - ("xai.wire_profile.target_arch", self.target_arch.as_str()), - ] { - if value.is_empty() - || value.len() > 64 - || !value.bytes().all(|byte| byte.is_ascii_graphic()) - { - return Err(XaiConfigError::InvalidField(field)); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum XaiConfigError { - #[error("xAI configuration field is invalid: {0}")] - InvalidField(&'static str), - #[error("xAI official protocol configuration is invalid")] - InvalidProtocol, -} diff --git a/backend/crates/providers/xai/src/lib.rs b/backend/crates/providers/xai/src/lib.rs index a52cc0e93..dce876cf4 100644 --- a/backend/crates/providers/xai/src/lib.rs +++ b/backend/crates/providers/xai/src/lib.rs @@ -1,13 +1,11 @@ //! 官方 Grok Build OAuth Provider 边界。 mod admin; -pub mod config; pub mod credential; mod provider; mod reasoning_replay; pub mod transport; -use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -21,8 +19,7 @@ use gateway_core::task::WorkerContribution; use crate::admin::{XaiAdminProvider, XaiAdminServices}; use crate::transport::profile::OfficialGrokCliReleaseTransport; -pub use config::{XaiConfig, XaiConfigError, XaiWireProfileConfig}; -pub use transport::XaiWireProfileState; +pub use transport::{XaiWireProfile, XaiWireProfileState}; pub use credential::{ AllowedRedirectUri, AuthorizationCallback, AuthorizationCodeGrant, CallbackRejection, @@ -91,15 +88,9 @@ pub struct ProviderBundle { } /// 构造 xAI 数据面、管理面准备器与 Provider-owned 后台任务。 -pub async fn initialize( - mut config: XaiConfig, - ports: ProviderStorePorts, -) -> Result { - config - .resolve_and_validate(Path::new(".")) - .map_err(XaiInitializeError::Config)?; - let profile = config.wire_profile_state(); - let oauth_config = config.oauth_config().map_err(XaiInitializeError::Config)?; +pub async fn initialize(ports: ProviderStorePorts) -> Result { + let profile = XaiWireProfileState::new(XaiWireProfile::default()); + let oauth_config = GrokOAuthConfig::official().map_err(XaiInitializeError::Config)?; let cli_release = Arc::new(GrokCliReleaseService::new( profile.clone(), Arc::new( @@ -114,6 +105,15 @@ pub async fn initialize( let cooldowns = ports.cooldowns(); let account_feedback = ports.account_feedback(); let runtime_policy = ports.runtime_policy(); + let initial_profile = transport::client_profile::GrokClientProfileSelection::default() + .document() + .map_err(|_| XaiInitializeError::RuntimePolicy)?; + let configured_profile = runtime_policy + .initialize_request_profile(&provider_kind, initial_profile) + .await + .map_err(|_| XaiInitializeError::RuntimePolicy)?; + transport::client_profile::GrokClientProfileSelection::parse(&configured_profile) + .map_err(|_| XaiInitializeError::RuntimePolicy)?; let repository = GrokCredentialRepository::new(Arc::clone(&accounts)); let endpoint_policy: Arc = Arc::new(OfficialGrokEndpointPolicy); @@ -241,7 +241,7 @@ impl ProviderBundle { #[derive(Debug, thiserror::Error)] pub enum XaiInitializeError { #[error(transparent)] - Config(XaiConfigError), + Config(ConfigError), #[error("xAI runtime policy is unavailable")] RuntimePolicy, #[error("xAI Provider kind is invalid")] diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index 1616a25a3..147152c51 100644 --- a/backend/crates/providers/xai/src/provider/mod.rs +++ b/backend/crates/providers/xai/src/provider/mod.rs @@ -129,6 +129,23 @@ impl GrokBuildProvider { #[async_trait] impl Provider for GrokBuildProvider { + fn resolve_request_profile( + &self, + configuration: &gateway_core::account::OpaqueProviderData, + ) -> Result { + use crate::transport::client_profile::{GrokClientProfileSelection, object}; + let profile = GrokClientProfileSelection::parse(configuration) + .and_then(|selection| selection.resolve(&self.wire_profile)) + .and_then(|profile| object(&profile)) + .map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?; + Ok(profile) + } + fn name(&self) -> &'static str { XAI_PROVIDER_NAME } @@ -203,6 +220,25 @@ impl Provider for GrokBuildProvider { } impl GrokBuildProvider { + fn request_wire_profile( + &self, + context: &AttemptContext, + ) -> Result { + let profile = match context.request_profile() { + Some(profile) => serde_json::from_value(serde_json::Value::Object( + profile.expose_to_provider().clone(), + )) + .map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?, + None => self.wire_profile.snapshot(), + }; + Ok(XaiWireProfileState::new(profile)) + } + async fn execute_generate( &self, generate: &GenerateRequest, @@ -311,7 +347,7 @@ impl GrokBuildProvider { Arc::clone(&self.transport), GrokStreamAttempt { client_identity: self.client_identity.clone(), - wire_profile: self.wire_profile.clone(), + wire_profile: self.request_wire_profile(&context)?, credential_recovery: Arc::clone(&self.credential_recovery), responses_url: self.responses_url.clone(), request: upstream_request, @@ -405,7 +441,7 @@ impl GrokBuildProvider { Arc::clone(&self.transport), GrokCompactionStreamAttempt { client_identity: self.client_identity.clone(), - wire_profile: self.wire_profile.clone(), + wire_profile: self.request_wire_profile(&context)?, credential_recovery: Arc::clone(&self.credential_recovery), responses_url: self.responses_url.clone(), request: upstream_request, diff --git a/backend/crates/providers/xai/src/transport/client_profile.rs b/backend/crates/providers/xai/src/transport/client_profile.rs new file mode 100644 index 000000000..de189eb39 --- /dev/null +++ b/backend/crates/providers/xai/src/transport/client_profile.rs @@ -0,0 +1,131 @@ +//! Grok CLI 管理配置与请求级身份解析;发布版本与用户选择分别维护。 + +use gateway_core::account::OpaqueProviderData; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{XaiWireProfile, XaiWireProfileState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VersionMode { + Latest, + Fixed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrokClientProfileSelection { + pub version_mode: VersionMode, + pub client_version: Option, + pub client_identifier: String, + pub client_mode: String, + pub target_os: String, + pub target_arch: String, +} + +impl Default for GrokClientProfileSelection { + fn default() -> Self { + let profile = XaiWireProfile::default(); + Self { + version_mode: VersionMode::Latest, + client_version: None, + client_identifier: profile.client_identifier, + client_mode: profile.client_mode, + target_os: profile.target_os, + target_arch: profile.target_arch, + } + } +} + +impl GrokClientProfileSelection { + pub fn parse(document: &OpaqueProviderData) -> Result { + let selection: Self = + serde_json::from_value(Value::Object(document.expose_to_provider().clone())) + .map_err(|_| ClientProfileError)?; + selection.validate()?; + Ok(selection) + } + + pub fn document(&self) -> Result { + self.validate()?; + object(self) + } + + fn validate(&self) -> Result<(), ClientProfileError> { + for value in [ + &self.client_identifier, + &self.client_mode, + &self.target_os, + &self.target_arch, + ] { + if value.is_empty() + || value.len() > 64 + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(ClientProfileError); + } + } + match (self.version_mode, self.client_version.as_deref()) { + (VersionMode::Latest, None) => Ok(()), + (VersionMode::Fixed, Some(version)) + if version.len() <= 64 && semver::Version::parse(version).is_ok() => + { + Ok(()) + } + _ => Err(ClientProfileError), + } + } + + pub fn resolve( + &self, + state: &XaiWireProfileState, + ) -> Result { + self.validate()?; + let mut profile = state.snapshot(); + if let Some(version) = &self.client_version { + profile.client_version.clone_from(version); + profile.verified_at = chrono::DateTime::UNIX_EPOCH; + } + profile + .client_identifier + .clone_from(&self.client_identifier); + profile.client_mode.clone_from(&self.client_mode); + profile.target_os.clone_from(&self.target_os); + profile.target_arch.clone_from(&self.target_arch); + Ok(profile) + } + + pub fn preview( + &self, + state: &XaiWireProfileState, + status: &super::profile::GrokCliReleaseSnapshot, + ) -> Result { + let profile = self.resolve(state)?; + let latest = self.version_mode == VersionMode::Latest; + object(&json!({ + "configuration": self, + "clientVersion": profile.client_version, + "clientIdentifier": profile.client_identifier, + "clientMode": profile.client_mode, + "targetOs": profile.target_os, + "targetArch": profile.target_arch, + "userAgent": profile.user_agent(), + "versionSource": if latest { "official" } else { "custom" }, + "verifiedAt": latest.then_some(profile.verified_at), + "checkedAt": latest.then_some(status.checked_at).flatten(), + "error": if latest { status.last_error.as_deref() } else { None }, + })) + } +} + +pub fn object(value: &impl Serialize) -> Result { + match serde_json::to_value(value).map_err(|_| ClientProfileError)? { + Value::Object(fields) => Ok(OpaqueProviderData::new(fields)), + _ => Err(ClientProfileError), + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Grok CLI 客户端身份字段或版本组合不合法")] +pub struct ClientProfileError; diff --git a/backend/crates/providers/xai/src/transport/mod.rs b/backend/crates/providers/xai/src/transport/mod.rs index 7836a0102..75413b664 100644 --- a/backend/crates/providers/xai/src/transport/mod.rs +++ b/backend/crates/providers/xai/src/transport/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod canonical; pub(crate) mod catalog; +pub mod client_profile; pub(crate) mod compaction; pub(crate) mod config; pub(crate) mod egress; @@ -47,7 +48,7 @@ pub use network::{ GrokReqwestTransportBuildError, OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport, }; -pub use profile::XaiWireProfileState; +pub use profile::{XaiWireProfile, XaiWireProfileState}; pub use request::{GrokRequestEncodeError, GrokResponsesRequest}; pub use session::{ GrokCredentialFailure, GrokCredentialFeedbackFuture, GrokSessionAffinityKey, diff --git a/backend/crates/providers/xai/src/transport/network.rs b/backend/crates/providers/xai/src/transport/network.rs index fcfb5d347..6436dbc11 100644 --- a/backend/crates/providers/xai/src/transport/network.rs +++ b/backend/crates/providers/xai/src/transport/network.rs @@ -684,6 +684,8 @@ fn build_official_client( proxy: Option<&OutboundProxy>, ) -> Result { let mut builder = Client::builder() + // 工作区可能同时启用 native-tls;与官方 Grok CLI 一样显式固定 rustls,避免握手画像漂移。 + .use_rustls_tls() .redirect(Policy::none()) .no_proxy() .connect_timeout(CONNECT_TIMEOUT) diff --git a/backend/crates/providers/xai/src/transport/profile.rs b/backend/crates/providers/xai/src/transport/profile.rs index a299ac50e..aeab85cb3 100644 --- a/backend/crates/providers/xai/src/transport/profile.rs +++ b/backend/crates/providers/xai/src/transport/profile.rs @@ -8,17 +8,54 @@ use std::{ use chrono::{DateTime, Utc}; use futures::{StreamExt as _, future::BoxFuture}; use reqwest::{Client, redirect::Policy}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; -use crate::config::XaiWireProfileConfig; +/// Grok CLI 运行时身份;默认值作为官方发布检查的内置基线。 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct XaiWireProfile { + pub client_identifier: String, + pub client_version: String, + pub client_mode: String, + pub target_os: String, + pub target_arch: String, + pub verified_at: DateTime, +} + +impl Default for XaiWireProfile { + fn default() -> Self { + Self { + client_identifier: "grok-shell".to_owned(), + client_version: "1.0.13".to_owned(), + client_mode: "headless".to_owned(), + target_os: "linux".to_owned(), + target_arch: "x86_64".to_owned(), + verified_at: DateTime::UNIX_EPOCH + chrono::Duration::seconds(1_788_105_600), + } + } +} + +impl XaiWireProfile { + #[must_use] + pub fn user_agent(&self) -> String { + // Grok CLI 的产品名独立于 x-grok-client-identifier;自定义请求头不替换 UA 产品名。 + let arch = match self.target_arch.as_str() { + "arm64" => "aarch64", + arch => arch, + }; + format!( + "grok-shell/{} ({}; {arch})", + self.client_version, self.target_os + ) + } +} #[derive(Debug, Clone)] -pub struct XaiWireProfileState(Arc>); +pub struct XaiWireProfileState(Arc>); impl XaiWireProfileState { #[must_use] - pub fn new(profile: XaiWireProfileConfig) -> Self { + pub fn new(profile: XaiWireProfile) -> Self { Self(Arc::new(RwLock::new(profile))) } @@ -49,18 +86,11 @@ impl XaiWireProfileState { #[must_use] pub fn user_agent(&self) -> String { - let profile = self.snapshot(); - format!( - "{}/{} ({}; {})", - profile.client_identifier, - profile.client_version, - profile.target_os, - profile.target_arch - ) + self.snapshot().user_agent() } #[must_use] - pub fn snapshot(&self) -> XaiWireProfileConfig { + pub fn snapshot(&self) -> XaiWireProfile { self.0 .read() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -74,6 +104,7 @@ impl XaiWireProfileState { .unwrap_or_else(std::sync::PoisonError::into_inner); if profile.client_version != version { profile.client_version = version.to_owned(); + profile.verified_at = Utc::now(); } } } diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 92bb0eed1..eca864a53 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -48,7 +48,7 @@ use sha2::{Digest as _, Sha256}; use crate::support::{ MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, TestSessionAffinity, - TestSessionExclusions, account_id, create_input, seed_input, xai_config, + TestSessionExclusions, account_id, create_input, seed_input, }; #[tokio::test] @@ -83,7 +83,7 @@ async fn account_unavailable_clears_real_selector_cooldowns_only_for_deleted_acc cooldowns.clone(), ports.account_feedback(), ); - let bundle = provider_xai::initialize(xai_config(), ports).await.unwrap(); + let bundle = provider_xai::initialize(ports).await.unwrap(); let models = ["grok-4.5", "grok-4.6"].map(|id| UpstreamModelId::new(id).unwrap()); for suffix in ["deleted-cooldown", "retained-cooldown"] { let input = create_input(suffix, &format!("subject-{suffix}")); @@ -143,7 +143,7 @@ async fn account_unavailable_clears_real_selector_cooldowns_only_for_deleted_acc #[tokio::test] async fn xai_bundle_exposes_core_admin_and_drains_worker_contributions_once() { - let mut bundle = provider_xai::initialize(xai_config(), provider_ports()) + let mut bundle = provider_xai::initialize(provider_ports()) .await .expect("xAI bundle"); @@ -166,7 +166,7 @@ async fn xai_bundle_exposes_core_admin_and_drains_worker_contributions_once() { #[tokio::test] async fn xai_quota_catalog_worker_treats_empty_account_pool_as_idle() { - let mut bundle = provider_xai::initialize(xai_config(), provider_ports()) + let mut bundle = provider_xai::initialize(provider_ports()) .await .expect("xAI bundle"); @@ -177,10 +177,10 @@ async fn xai_quota_catalog_worker_treats_empty_account_pool_as_idle() { async fn xai_quota_catalog_worker_preserves_store_failures() { let store = Arc::new(MemoryProviderAccountStore::default()); store.fail_provider_listing(); - let mut bundle = provider_xai::initialize( - xai_config(), - provider_ports_with(store, Arc::new(TestOAuthPending::default())), - ) + let mut bundle = provider_xai::initialize(provider_ports_with( + store, + Arc::new(TestOAuthPending::default()), + )) .await .expect("xAI bundle"); @@ -219,15 +219,15 @@ async fn run_quota_catalog_cycle( #[tokio::test] async fn xai_admin_provider_validates_known_billing_breakdown() { - let bundle = provider_xai::initialize(xai_config(), provider_ports()) + let bundle = provider_xai::initialize(provider_ports()) .await .expect("xAI bundle"); let admin = bundle.admin_provider(); let profile = admin.dashboard_wire_profile().expect("wire profile"); assert_eq!(profile.provider, "xai"); assert_eq!(profile.product, "Grok Build"); - assert_eq!(profile.version, "0.2.106"); - assert_eq!(profile.user_agent, "grok-shell/0.2.106 (linux; x86_64)"); + assert_eq!(profile.version, "1.0.13"); + assert_eq!(profile.user_agent, "grok-shell/1.0.13 (linux; x86_64)"); let release = profile.release.expect("release status"); assert_eq!(release.status, DesktopReleaseStatus::Unchecked); assert!(release.checked_at.is_none()); @@ -276,13 +276,10 @@ async fn xai_admin_provider_validates_known_billing_breakdown() { #[tokio::test] async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() { let pending = Arc::new(TestOAuthPending::default()); - let bundle = provider_xai::initialize( - xai_config(), - provider_ports_with( - Arc::new(MemoryProviderAccountStore::default()), - Arc::clone(&pending), - ), - ) + let bundle = provider_xai::initialize(provider_ports_with( + Arc::new(MemoryProviderAccountStore::default()), + Arc::clone(&pending), + )) .await .expect("xAI bundle"); let owner = MutationContext { @@ -386,14 +383,11 @@ async fn xai_admin_provider_projects_cached_quota_models_and_canonical_export() record.outbound_proxy = Some(proxy.clone()); let catalog_cache = Arc::new(TestCatalogCache::default()); catalog_cache.seed("plan:standard", ["grok-4.5"]); - let bundle = provider_xai::initialize( - xai_config(), - provider_ports_with_catalog( - Arc::clone(&store), - Arc::new(TestOAuthPending::default()), - catalog_cache, - ), - ) + let bundle = provider_xai::initialize(provider_ports_with_catalog( + Arc::clone(&store), + Arc::new(TestOAuthPending::default()), + catalog_cache, + )) .await .expect("xAI bundle"); let admin = bundle.admin_provider(); @@ -497,10 +491,10 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() { seed_input(&store, &input).await.expect("create account"); let account = store.account(&input.account_id).expect("stored account"); let record = account_record(&account); - let bundle = provider_xai::initialize( - xai_config(), - provider_ports_with(store, Arc::new(TestOAuthPending::default())), - ) + let bundle = provider_xai::initialize(provider_ports_with( + store, + Arc::new(TestOAuthPending::default()), + )) .await .expect("xAI bundle"); let admin = bundle.admin_provider(); @@ -1075,10 +1069,10 @@ mod errors { if !expired { account.upstream_user_id = Some("previous-subject".to_owned()); } - let bundle = provider_xai::initialize( - xai_config(), - provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), - ) + let bundle = provider_xai::initialize(provider_ports_with( + store.clone(), + Arc::new(TestOAuthPending::default()), + )) .await .unwrap(); let error = bundle @@ -1132,7 +1126,7 @@ mod errors { Arc::new(TestRuntimePolicy), Arc::new(TestOAuthPending::default()), ); - let bundle = provider_xai::initialize(xai_config(), ports).await.unwrap(); + let bundle = provider_xai::initialize(ports).await.unwrap(); let error = bundle .admin_provider() .prepare_refresh(PrepareCredentialRefresh { account }) @@ -1161,10 +1155,10 @@ mod errors { let before = store.account(&input.account_id).unwrap(); let mut account = account_record(&before); account.outbound_proxy = before.outbound_proxy().cloned(); - let bundle = provider_xai::initialize( - xai_config(), - provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), - ) + let bundle = provider_xai::initialize(provider_ports_with( + store.clone(), + Arc::new(TestOAuthPending::default()), + )) .await .unwrap(); let error = bundle @@ -1208,3 +1202,37 @@ mod errors { } } } + +#[tokio::test] +async fn client_profile_preview_and_dashboard_use_saved_configuration() { + use provider_xai::transport::client_profile::{GrokClientProfileSelection, VersionMode}; + let bundle = provider_xai::initialize(provider_ports()).await.unwrap(); + let admin = bundle.admin_provider(); + let options = admin.client_profile_options().unwrap(); + assert_eq!( + options.expose_to_provider()["defaults"]["versionMode"], + "latest" + ); + let selection = GrokClientProfileSelection { + version_mode: VersionMode::Fixed, + client_version: Some("8.7.6".to_owned()), + target_os: "windows".to_owned(), + target_arch: "arm64".to_owned(), + ..Default::default() + }; + let configuration = selection.document().unwrap(); + let preview = admin.preview_client_profile(&configuration).unwrap(); + assert_eq!(preview.expose_to_provider()["clientVersion"], "8.7.6"); + assert_eq!(preview.expose_to_provider()["versionSource"], "custom"); + assert!(preview.expose_to_provider()["verifiedAt"].is_null()); + let dashboard = admin.configured_wire_profile(&configuration).unwrap(); + assert_eq!(dashboard.version, "8.7.6"); + assert_eq!(dashboard.target.os_type, "windows"); + assert_eq!(dashboard.target.arch, "arm64"); + assert_eq!( + dashboard.user_agent, + preview.expose_to_provider()["userAgent"].as_str().unwrap() + ); + assert!(dashboard.release.is_none()); + assert!(dashboard.verified_at.is_none()); +} diff --git a/backend/crates/providers/xai/tests/config.rs b/backend/crates/providers/xai/tests/config.rs deleted file mode 100644 index a86f169e2..000000000 --- a/backend/crates/providers/xai/tests/config.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::path::Path; - -use provider_xai::XaiConfig; - -use crate::support::xai_config; - -#[test] -fn xai_config_requires_and_freezes_the_explicit_wire_profile() { - let mut config = xai_config(); - config - .resolve_and_validate(Path::new("/srv/gateway")) - .expect("valid xAI wire profile"); - - let profile = config.wire_profile_state(); - assert_eq!(profile.client_version(), "0.2.106"); - assert_eq!(profile.user_agent(), "grok-shell/0.2.106 (linux; x86_64)"); - assert!(serde_json::from_str::("{}").is_err()); - assert!(serde_json::from_str::( - r#"{"wire_profile":{"client_identifier":"grok-shell","client_version":"0.2.106","client_mode":"headless","target_os":"linux","target_arch":"x86_64","verified_at":"2026-07-21T00:00:00+08:00","unknown":true}}"#, - ) - .is_ok()); -} diff --git a/backend/crates/providers/xai/tests/main.rs b/backend/crates/providers/xai/tests/main.rs index c4aa9a5f2..6bdd89bc9 100644 --- a/backend/crates/providers/xai/tests/main.rs +++ b/backend/crates/providers/xai/tests/main.rs @@ -1,5 +1,4 @@ mod admin; -mod config; mod credential; mod provider; mod support; diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 633b1d0b7..a62553caf 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -3446,3 +3446,55 @@ async fn missing_catalog_feature_metadata_keeps_build_responses_routable() { .is_some() ); } + +#[tokio::test] +async fn configured_request_profile_reaches_inference_headers() { + use provider_xai::transport::client_profile::{GrokClientProfileSelection, VersionMode}; + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let selection = GrokClientProfileSelection { + version_mode: VersionMode::Fixed, + client_version: Some("9.8.7".to_owned()), + client_identifier: "profile-contract".to_owned(), + target_os: "windows".to_owned(), + target_arch: "arm64".to_owned(), + ..Default::default() + }; + let resolved = provider + .resolve_request_profile(&selection.document().unwrap()) + .unwrap(); + let context = AttemptContext::new( + gateway_core::engine::RequestAttemptContext::new( + ModelRequestId::new("req_profile").unwrap(), + ClientApiKeyId::new("key_profile").unwrap(), + ) + .with_request_profile(Some(resolved)), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(30), + selection_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None), + None, + CancellationToken::new(), + ); + let events = provider + .execute(provider_request("xai"), context) + .await + .unwrap() + .collect::>() + .await; + assert!(events.iter().all(Result::is_ok)); + let requests = transport.requests.lock().unwrap(); + let headers = requests[0].headers(); + let header = |name| { + headers + .iter() + .find(|header| header.name() == name) + .unwrap() + .value() + .expose() + }; + assert_eq!(header("x-grok-client-version"), "9.8.7"); + assert_eq!(header("x-grok-client-identifier"), "profile-contract"); + assert_eq!(header("x-grok-client-mode"), "headless"); + assert_eq!(header("user-agent"), "grok-shell/9.8.7 (windows; aarch64)"); +} diff --git a/backend/crates/providers/xai/tests/support.rs b/backend/crates/providers/xai/tests/support.rs index 2b7ff0c7a..417ca6743 100644 --- a/backend/crates/providers/xai/tests/support.rs +++ b/backend/crates/providers/xai/tests/support.rs @@ -27,28 +27,25 @@ use provider_xai::{ GrokCatalogCacheError, GrokCatalogScope, GrokCredentialAdmin, GrokCredentialCatalogCache, GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRepository, GrokCredentialRepositoryError, GrokEndpointPolicy, GrokModelCatalogTransport, GrokOAuthSecret, - GrokPlanCatalog, GrokReqwestTransportBuildError, SecretValue, XaiConfig, XaiWireProfileState, + GrokPlanCatalog, GrokReqwestTransportBuildError, SecretValue, XaiWireProfile, + XaiWireProfileState, }; use reqwest::Client; use reqwest::redirect::Policy; use url::{Host, Url}; -pub fn xai_config() -> XaiConfig { - serde_json::from_value(serde_json::json!({ - "wire_profile": { +pub fn xai_wire_profile() -> XaiWireProfileState { + XaiWireProfileState::new( + serde_json::from_value::(serde_json::json!({ "client_identifier": "grok-shell", "client_version": "0.2.106", "client_mode": "headless", "target_os": "linux", "target_arch": "x86_64", "verified_at": "2026-07-21T00:00:00+08:00" - } - })) - .expect("valid xAI test config") -} - -pub fn xai_wire_profile() -> XaiWireProfileState { - xai_config().wire_profile_state() + })) + .expect("valid test runtime profile"), + ) } pub fn grok_catalog_service( diff --git a/backend/crates/providers/xai/tests/transport/client_profile.rs b/backend/crates/providers/xai/tests/transport/client_profile.rs new file mode 100644 index 000000000..029e15102 --- /dev/null +++ b/backend/crates/providers/xai/tests/transport/client_profile.rs @@ -0,0 +1,58 @@ +use gateway_core::account::OpaqueProviderData; +use provider_xai::transport::client_profile::{GrokClientProfileSelection, VersionMode}; +use provider_xai::{XaiWireProfile, XaiWireProfileState}; +use serde_json::json; + +#[test] +fn client_profile_preserves_environment_and_fixed_version_independently_of_release() { + let initial = XaiWireProfile::default(); + let mut selection = GrokClientProfileSelection { + target_os: "windows".to_owned(), + target_arch: "arm64".to_owned(), + ..Default::default() + }; + let state = XaiWireProfileState::new(XaiWireProfile { + client_version: "2.0.0".to_owned(), + ..initial + }); + let automatic = selection.resolve(&state).unwrap(); + assert_eq!(automatic.client_version, "2.0.0"); + assert_eq!(automatic.target_os, "windows"); + selection.version_mode = VersionMode::Fixed; + selection.client_version = Some("1.0.13".to_owned()); + let fixed = selection.resolve(&state).unwrap(); + assert_eq!( + XaiWireProfileState::new(fixed).user_agent(), + "grok-shell/1.0.13 (windows; aarch64)" + ); + assert_eq!(state.client_version(), "2.0.0"); + let document = selection.document().unwrap(); + assert_eq!( + GrokClientProfileSelection::parse(&document).unwrap(), + selection + ); +} + +#[test] +fn client_profile_rejects_header_injection_and_inconsistent_version_modes() { + let valid = serde_json::to_value(GrokClientProfileSelection::default()).unwrap(); + for (field, invalid) in [ + ("clientIdentifier", json!("Grok\r\nAuthorization: injected")), + ("clientMode", json!("")), + ("targetOs", json!("linux; injected")), + ("targetArch", json!("x".repeat(65))), + ("clientVersion", json!("1.0.13")), + ("versionMode", json!("fixed")), + ("unknown", json!(true)), + ] { + let mut document = valid.clone(); + document[field] = invalid; + assert!( + GrokClientProfileSelection::parse(&OpaqueProviderData::new( + document.as_object().unwrap().clone() + )) + .is_err(), + "{field}" + ); + } +} diff --git a/backend/crates/providers/xai/tests/transport/headers.rs b/backend/crates/providers/xai/tests/transport/headers.rs index e294dff96..50bf81c9d 100644 --- a/backend/crates/providers/xai/tests/transport/headers.rs +++ b/backend/crates/providers/xai/tests/transport/headers.rs @@ -90,6 +90,33 @@ fn headers_should_bind_identity_to_the_selected_oauth_account() { ); } +#[test] +fn custom_client_identifier_should_not_replace_grok_shell_user_agent() { + let mut profile = crate::support::xai_wire_profile().snapshot(); + profile.client_identifier = "custom-client".to_owned(); + let headers = build_grok_headers( + &provider_xai::XaiWireProfileState::new(profile), + &selected_session(), + &GrokClientIdentity::new(), + &ModelRequestId::new("req_custom_identifier").expect("request ID"), + None, + None, + &UpstreamModelId::new("grok-code-test").expect("model"), + ); + let value = |name: &str| { + headers + .iter() + .find(|header| header.name() == name) + .map(|header| header.value().expose()) + }; + + assert_eq!(value("x-grok-client-identifier"), Some("custom-client")); + assert_eq!( + value("user-agent"), + Some("grok-shell/0.2.106 (linux; x86_64)") + ); +} + #[test] fn headers_should_send_both_stable_session_headers_without_inventing_a_turn_index() { let headers = build_grok_headers( diff --git a/backend/crates/providers/xai/tests/transport/mod.rs b/backend/crates/providers/xai/tests/transport/mod.rs index bb9548173..317206b03 100644 --- a/backend/crates/providers/xai/tests/transport/mod.rs +++ b/backend/crates/providers/xai/tests/transport/mod.rs @@ -1,5 +1,6 @@ mod canonical; mod catalog; +mod client_profile; mod compaction; mod config; mod egress; diff --git a/backend/crates/providers/xai/tests/transport/network.rs b/backend/crates/providers/xai/tests/transport/network.rs index dcb8872c2..24ce236a3 100644 --- a/backend/crates/providers/xai/tests/transport/network.rs +++ b/backend/crates/providers/xai/tests/transport/network.rs @@ -10,6 +10,7 @@ use futures::StreamExt; use gateway_core::upstream::UpstreamSendState; use reqwest::Client; use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use url::Url; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -26,6 +27,97 @@ use provider_xai::{ use crate::support::{loopback_endpoint_policy, rejecting_account_proxy}; +#[tokio::test] +async fn official_inference_tls_should_advertise_grok_cli_alpn() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = gateway_core::account::OutboundProxy::parse(&format!( + "http://{}", + listener.local_addr().unwrap() + )) + .unwrap(); + let capture = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut connect = Vec::new(); + while !connect.ends_with(b"\r\n\r\n") { + assert!(connect.len() < 8192); + connect.push(socket.read_u8().await.unwrap()); + } + assert!(connect.starts_with(b"CONNECT cli-chat-proxy.grok.com:443 HTTP/1.1\r\n")); + socket + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .unwrap(); + // 只读取公开的 ClientHello 后关闭连接,不发送证书,也不接收认证和模型请求。 + let mut handshake = Vec::new(); + loop { + assert_eq!(socket.read_u8().await.unwrap(), 22); + let _record_version = socket.read_u16().await.unwrap(); + let record_len = usize::from(socket.read_u16().await.unwrap()); + assert!(record_len <= 16384); + let start = handshake.len(); + handshake.resize(start + record_len, 0); + socket.read_exact(&mut handshake[start..]).await.unwrap(); + assert!(handshake.len() <= 65536); + if handshake.len() >= 4 { + assert_eq!(handshake[0], 1); + let size = u32::from_be_bytes([0, handshake[1], handshake[2], handshake[3]]); + if handshake.len() >= 4 + size as usize { + return client_hello_alpn(&handshake[4..]); + } + } + } + }); + let transport = + ReqwestGrokInferenceTransport::new(Arc::new(OfficialGrokEndpointPolicy)).unwrap(); + let request = GrokInferenceRequest::new( + Url::parse("https://cli-chat-proxy.grok.com/v1/responses").unwrap(), + Vec::new(), + b"{}".to_vec(), + GrokSessionBinding::new("tls-contract") + .unwrap() + .with_outbound_proxy(Some(proxy)), + ); + let (response, alpn) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(transport.execute(request), capture) + }) + .await + .unwrap(); + assert!(response.is_err(), "捕获端在 TLS 握手后主动关闭"); + assert_eq!(alpn.unwrap(), vec![b"h2".to_vec(), b"http/1.1".to_vec()]); +} + +fn client_hello_alpn(mut hello: &[u8]) -> Vec> { + fn take<'a>(input: &mut &'a [u8], len: usize) -> &'a [u8] { + let (value, rest) = input.split_at(len); + *input = rest; + value + } + fn vector<'a>(input: &mut &'a [u8], prefix: usize) -> &'a [u8] { + let len = take(input, prefix) + .iter() + .fold(0_usize, |len, byte| len * 256 + usize::from(*byte)); + take(input, len) + } + take(&mut hello, 34); // 跳过版本号与随机值。 + vector(&mut hello, 1); // 跳过会话 ID。 + vector(&mut hello, 2); // 跳过密码套件列表。 + vector(&mut hello, 1); // 跳过压缩方法列表。 + let mut extensions = vector(&mut hello, 2); + while !extensions.is_empty() { + let kind = u16::from_be_bytes(take(&mut extensions, 2).try_into().unwrap()); + let mut data = vector(&mut extensions, 2); + if kind == 16 { + let mut protocols = vector(&mut data, 2); + let mut alpn = Vec::new(); + while !protocols.is_empty() { + alpn.push(vector(&mut protocols, 1).to_vec()); + } + return alpn; + } + } + Vec::new() +} + #[tokio::test] async fn inference_and_oauth_connect_through_each_accounts_authenticated_proxy() { let inference = diff --git a/backend/crates/providers/xai/tests/transport/profile.rs b/backend/crates/providers/xai/tests/transport/profile.rs index d6e30a8d8..0a22a77c7 100644 --- a/backend/crates/providers/xai/tests/transport/profile.rs +++ b/backend/crates/providers/xai/tests/transport/profile.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use chrono::Utc; use futures::future::BoxFuture; use provider_xai::{ - GrokCliReleaseError, GrokCliReleaseService, GrokCliReleaseTransport, XaiWireProfileConfig, + GrokCliReleaseError, GrokCliReleaseService, GrokCliReleaseTransport, XaiWireProfile, XaiWireProfileState, }; @@ -12,6 +12,25 @@ struct ReleaseTransport { outcomes: Mutex>>, } +#[test] +fn default_identity_should_match_official_grok_shell() { + let profile = XaiWireProfile::default(); + + assert_eq!(profile.client_identifier, "grok-shell"); + assert_eq!(profile.user_agent(), "grok-shell/1.0.13 (linux; x86_64)"); +} + +#[test] +fn user_agent_should_normalize_arm64_like_official_cli() { + let profile = XaiWireProfile { + target_os: "macos".to_owned(), + target_arch: "arm64".to_owned(), + ..XaiWireProfile::default() + }; + + assert_eq!(profile.user_agent(), "grok-shell/1.0.13 (macos; aarch64)"); +} + impl ReleaseTransport { fn new(outcomes: impl IntoIterator>) -> Self { Self { @@ -77,7 +96,7 @@ async fn failed_release_refresh_should_keep_last_successful_version() { } fn wire_profile() -> XaiWireProfileState { - XaiWireProfileState::new(XaiWireProfileConfig { + XaiWireProfileState::new(XaiWireProfile { client_identifier: "grok-shell".to_owned(), client_version: "0.2.106".to_owned(), client_mode: "headless".to_owned(), diff --git a/deploy/README.md b/deploy/README.md index 3da36413c..232074849 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -64,12 +64,14 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。 `config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。 -OpenAI 上游身份在管理端「系统设置 → 上游配置 → 客户端身份」配置,Key 可选择独立身份覆盖通用值。 -首次默认 macOS Desktop 自动最新,支持范围与字段见 [客户端身份合同](../docs/api.md#openai-上游客户端身份)。 -旧 `openai.wire_profile` 在数据库尚无选择时一次性导入:默认版本采用自动更新;版本元组、平台或架构 -与默认值不同则保留为固定版本。导入后从管理端修改,重启不会用 YAML 覆盖数据库。 -`residency` 仍为部署配置;后台账号与 Desktop 专属请求保持独立官方画像。 -OpenAI 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 默认值;xAI 仍在 YAML 配置启动画像。 +OpenAI 与 xAI 上游身份在管理端「系统设置 → 上游配置 → 客户端身份」配置,Key 可按 Provider 选择独立身份覆盖通用值。 +OpenAI 首次默认 macOS Desktop 自动最新,支持范围与字段见 [客户端身份合同](../docs/api.md#openai-上游客户端身份)。 +客户端身份只保存于数据库;首次使用内置默认值,重启不会覆盖已有选择。 +`openai.residency` 为独立的数据驻留部署约束,省略或 `null` 时不指定;后台账号与 Desktop 专属请求使用内置官方画像。 +OpenAI 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 默认值。 +xAI 无需 YAML 配置,首次默认 Grok CLI 的 Linux x86_64 身份并采用自动更新。 +管理端支持固定版本与自动更新;字段及生效范围见 [xAI 客户端身份](../docs/api.md#xai-上游客户端身份)。 +xAI 的后台 OAuth、目录和额度查询使用内置官方画像。 后台发布检查只更新可重建的版本资料,不回写用户选择或 `config.yaml`;失败继续使用同组合的有效版本。 全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 54573c14d..0f36267b9 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -64,22 +64,8 @@ api: request_id_header: 'x-request-id' openai: - # 上游客户端身份从管理端设置,默认 macOS Desktop 自动最新。 - # 旧版本的完整 wire_profile 仅在首次初始化数据库选择时导入。 - wire_profile: - # 数据驻留仍为部署配置;省略或 null 不指定地域约束。 - residency: us - -xai: - # OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。 - wire_profile: - # Provider 每 24 小时读取官方 npm metadata,仅在进程内更新 client_version。 - client_identifier: 'GrokShell' - client_version: '1.0.13' - client_mode: 'headless' - target_os: 'linux' - target_arch: 'x86_64' - verified_at: '2026-08-31T00:00:00+08:00' + # 数据驻留约束;省略或 null 不指定地域约束,与管理端客户端身份无关。 + residency: us # Compose 凭据桥接区;密码仍只维护在 store 中。 services: diff --git a/docs/api.md b/docs/api.md index b559fc837..b3a21ec8f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -893,13 +893,14 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 | `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 | 创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选 -`dailyLimitUsd`、`weeklyLimitUsd`、`customKey` 和 `openaiClientProfileOverride`。更新请求携带 `id`,不接受 `customKey`。 +`dailyLimitUsd`、`weeklyLimitUsd`、`customKey`、`openaiClientProfileOverride` 和 `xaiClientProfileOverride`。更新请求携带 `id`,不接受 `customKey`。 `groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生 `routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的 `providerKinds`。创建和 reveal 响应会返回完整明文 Key,调用方 必须立即安全保存。 `openaiClientProfileOverride` 为完整的 [OpenAI 客户端身份](#openai-上游客户端身份)对象或 `null`,列表也返回该字段。 +`xaiClientProfileOverride` 对应完整的 [xAI 客户端身份](#xai-上游客户端身份),两者分别覆盖所属 Provider,列表同时返回。 创建时省略或 `null` 表示跟随通用设置;更新时省略保留现值,显式 `null` 才清除覆盖。 独立配置整体覆盖通用设置,不逐字段继承;切换全局配置不会影响独立 Key。 @@ -970,6 +971,8 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 | | `GET` | `/api/admin/settings/client-profiles/openai` | 读取六个预设、自动更新可用状态和 `globalConfiguration` | | `POST` | `/api/admin/settings/client-profiles/openai/preview` | body 为 `{ configuration }`,值为完整身份对象或 `null`(解析当前通用设置);只预览,不保存 | +| `GET` | `/api/admin/settings/client-profiles/xai` | 读取 Grok CLI 默认字段 `defaults` 和 `globalConfiguration` | +| `POST` | `/api/admin/settings/client-profiles/xai/preview` | body 为 `{ configuration }`,值为完整 xAI 身份对象或 `null`(解析当前通用设置);只预览,不保存 | | `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 | | `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key | | `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key | @@ -1074,7 +1077,7 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI ### OpenAI 上游客户端身份 `openaiClientProfile` 保存通用选择,首次默认 `MacOS · Desktop · 自动最新`。 -设置更新省略该字段保留现值,不能提交 `null`。内置默认只用于初始化,不形成第三层运行时回退。 +设置更新省略该字段保留现值,不能提交 `null`。初始化不读取 YAML 身份字段;内置默认只用于初始化,不形成第三层运行时回退。 该配置作用于 Client Key 的 OpenAI 模型请求与原生模型目录,适用于 HTTP/SSE、WebSocket、Images 和 Search。 不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作。 @@ -1107,6 +1110,32 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI 配置在请求开始时冻结,Provider 首次解析的版本用于该请求的全部重试与换号。 已建立 WebSocket 的精确续写沿用所属连接;新请求使用保存后的选择。 +### xAI 上游客户端身份 + +`xaiClientProfile` 保存 Grok CLI 的通用身份选择;首次使用内置 `grok-shell / headless / linux / x86_64` +并采用自动更新版本。初始化不读取 YAML 身份字段,数据库已有选择时不覆盖。 +更新设置省略该字段保留现值,不能提交 `null`。 + +| 字段 | 取值与语义 | +| --- | --- | +| `versionMode` | 必填,`latest` 或 `fixed` | +| `clientVersion` | `fixed` 必填 SemVer,最多 64 字节;`latest` 必须省略或为 `null` | +| `clientIdentifier`、`clientMode`、`targetOs`、`targetArch` | 必填,各为 1~64 字节可见 ASCII,不含空白或控制字符 | + +```json +{"versionMode":"latest","clientVersion":null,"clientIdentifier":"grok-shell","clientMode":"headless","targetOs":"linux","targetArch":"x86_64"} +``` + +配置作用于 Client Key 的 xAI 模型请求和压缩请求,影响 `x-grok-client-version`、`x-grok-client-identifier`、 +`x-grok-client-mode` 与 User-Agent;OAuth、后台目录和额度查询使用 Provider 内置官方画像。 +User-Agent 使用 `grok-shell/<版本> (<系统>; <架构>)`,其中 `arm64` 按官方规则展示为 `aarch64`。 +`clientIdentifier` 只控制对应请求头,不替换 User-Agent 中的 `grok-shell` 产品名。 +每个请求开始时解析并冻结身份,重试和换号沿用该身份;保存后新请求生效,密钥独立配置优先于通用设置。 + +自动版本通过官方 npm 检查稳定版本,周期 24 小时;检查失败保留当前进程最近有效版本,重启以内置基线开始。 +固定版本不受后台更新影响。预览返回配置、来源、最终身份字段、`userAgent`、`versionSource`、`verifiedAt`、 +`checkedAt` 和 `error`;固定版本不附带官方核验时间。Dashboard 展示已保存的通用身份。 + ### 账号自动冻结 账号自动冻结(`accountAutoFreezeEnabled`)默认关闭。启用后,在统计窗口内按尝试累计容量类上游错误(`server_is_overloaded` diff --git a/docs/architecture.md b/docs/architecture.md index 0bdac5cff..b8045078d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -226,13 +226,15 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 - xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type 可以透出,但账号指纹会先脱敏。 - response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。 -- OpenAI 用户身份选择由 PostgreSQL 保存,Core 在 `FrozenAccountScope` 中按 Key 整体覆盖通用选择, +- OpenAI 与 xAI 用户身份选择由 PostgreSQL 保存,Core 在 `FrozenAccountScope` 中按 Key 整体覆盖通用选择, 以 Provider-owned 不透明对象沿路由计划传递;执行会话复用首次解析结果,使重试不受发布更新影响。 - OpenAI Provider 唯一负责预设、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。 - 官方发布资料与用户选择分开:Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存, + 各 Provider 唯一负责默认值、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。 + 首次初始化只写入内置默认选择;YAML 不定义客户端身份,也不作为数据库初始化或请求解析的来源。 + 官方发布资料与用户选择分开:OpenAI 在 Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存, Desktop 完整制品元组原子更新,固定配置不被刷新覆盖。普通连接按已有身份键匹配,精确续写保留原连接。 后台账号和 Desktop 专属操作使用独立官方 Desktop 画像,不接受 Key 覆盖。 - xAI 仍以启动配置为基线检查官方版本;两者均不回写 `config.yaml`。 + xAI 以内置画像为版本检查基线,在进程内更新 Grok CLI 发布资料;模型与压缩请求使用已保存的用户选择, + OAuth、后台目录和额度查询使用内置官方画像。两者均不回写 `config.yaml`。 xAI Provider 负责 Codex custom 工具与 Grok function 工具的双向转换,保持工具类型、item ID 与 `call_id` 配对;超限或转换失败终止流。默认 `store: false` 的续接由现有会话 owner 重放完整历史; diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/api-keys.ts index 3e55eb607..8f2707bb3 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/api-keys.ts @@ -1,6 +1,6 @@ import type { RequestOptions } from '../request' import type { AccountGroupRef } from './account-groups' -import type { ClientProfileSelection } from './client-profiles' +import type { ClientProfileSelection, XaiClientProfileSelection } from './client-profiles' import request from '../request' export type ApiKeyRoutingScope = 'all' | 'groups' @@ -8,6 +8,7 @@ export type ApiKeyBudgetPeriod = 'daily' | 'weekly' | 'all' export interface ApiKey { openaiClientProfileOverride: ClientProfileSelection | null + xaiClientProfileOverride: XaiClientProfileSelection | null id: string name: string @@ -62,6 +63,7 @@ interface ApiKeyListParams { export interface ApiKeyWriteParam { openaiClientProfileOverride: ClientProfileSelection | null + xaiClientProfileOverride: XaiClientProfileSelection | null name: string label: string | null diff --git a/frontend/src/api/modules/client-profiles.ts b/frontend/src/api/modules/client-profiles.ts index 9bee45268..c8b55a9ad 100644 --- a/frontend/src/api/modules/client-profiles.ts +++ b/frontend/src/api/modules/client-profiles.ts @@ -54,3 +54,39 @@ export function previewClientProfile(configuration: ClientProfileSelection | nul silent: true, }) } + +export interface XaiClientProfileSelection { + versionMode: 'latest' | 'fixed' + clientVersion: string | null + clientIdentifier: string + clientMode: string + targetOs: string + targetArch: string +} + +export interface XaiClientProfilePreview extends Omit { + configuration: XaiClientProfileSelection + source: 'global' | 'override' + userAgent: string + versionSource: 'official' | 'custom' + verifiedAt: string | null + checkedAt: string | null + error: string | null +} + +export function getXaiClientProfileOptions() { + return request<{ defaults: XaiClientProfileSelection, globalConfiguration: XaiClientProfileSelection }>({ + url: '/api/admin/settings/client-profiles/xai', + method: 'GET', + silent: true, + }) +} + +export function previewXaiClientProfile(configuration: XaiClientProfileSelection | null) { + return request({ + url: '/api/admin/settings/client-profiles/xai/preview', + method: 'POST', + data: { configuration }, + silent: true, + }) +} diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index b9b9dad01..5c4ecb790 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -1,12 +1,13 @@ import type { RequestOptions } from '../request' import type { RequestLocation } from '../types/request-location' -import type { ClientProfileSelection } from './client-profiles' +import type { ClientProfileSelection, XaiClientProfileSelection } from './client-profiles' import request from '../request' export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky' export interface RuntimeSettings { openaiClientProfile: ClientProfileSelection + xaiClientProfile: XaiClientProfileSelection requestLocationEnabled: boolean requestLocation: RequestLocation diff --git a/frontend/src/components/client-profile/ClientProfileEditor.vue b/frontend/src/components/client-profile/ClientProfileEditor.vue index c2439a459..db88ab09d 100644 --- a/frontend/src/components/client-profile/ClientProfileEditor.vue +++ b/frontend/src/components/client-profile/ClientProfileEditor.vue @@ -7,9 +7,8 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue' import BaseInput from '@/components/base/BaseInput.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import BaseSelect from '@/components/base/BaseSelect.vue' -import BaseSkeleton from '@/components/base/BaseSkeleton.vue' import { errorMessage } from '@/utils/async' -import { formatDateTime } from '@/utils/date' +import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue' const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), { active: true, @@ -191,33 +190,6 @@ onMounted(load) -
-

- 填写版本后预览 -

-
- - -
- - -
+ diff --git a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue new file mode 100644 index 000000000..b4066257a --- /dev/null +++ b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/components/client-profile/XaiClientProfileEditor.vue b/frontend/src/components/client-profile/XaiClientProfileEditor.vue new file mode 100644 index 000000000..a92175b4e --- /dev/null +++ b/frontend/src/components/client-profile/XaiClientProfileEditor.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/views/keys/components/ApiKeyCreateModal.vue b/frontend/src/views/keys/components/ApiKeyCreateModal.vue index bbfe7fef8..697a30d33 100644 --- a/frontend/src/views/keys/components/ApiKeyCreateModal.vue +++ b/frontend/src/views/keys/components/ApiKeyCreateModal.vue @@ -12,6 +12,7 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue' import BaseInput from '@/components/base/BaseInput.vue' import BaseModal from '@/components/base/BaseModal/index.vue' import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor.vue' +import XaiClientProfileEditor from '@/components/client-profile/XaiClientProfileEditor.vue' const props = defineProps<{ groups: AccountGroup[] @@ -87,9 +88,12 @@ const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key') /> - + + + +
diff --git a/frontend/src/views/keys/composables/useApiKeyMutations.ts b/frontend/src/views/keys/composables/useApiKeyMutations.ts index 13a590de0..5ccc527b2 100644 --- a/frontend/src/views/keys/composables/useApiKeyMutations.ts +++ b/frontend/src/views/keys/composables/useApiKeyMutations.ts @@ -1,6 +1,6 @@ import type { Ref } from 'vue' import type { getApiKeys } from '@/api' -import type { ClientProfileSelection } from '@/api/modules/client-profiles' +import type { ClientProfileSelection, XaiClientProfileSelection } from '@/api/modules/client-profiles' import { ref, shallowRef, watch } from 'vue' import { createApiKey, @@ -19,6 +19,7 @@ type ApiKeyRow = Awaited>['items'][number] export interface ApiKeyFormValue { openaiClientProfileOverride: ClientProfileSelection | null + xaiClientProfileOverride: XaiClientProfileSelection | null customKey: string name: string label: string @@ -65,6 +66,7 @@ export function useApiKeyMutations(options: { editingKey.value = key form.value = { openaiClientProfileOverride: key.openaiClientProfileOverride ? { ...key.openaiClientProfileOverride } : null, + xaiClientProfileOverride: key.xaiClientProfileOverride ? { ...key.xaiClientProfileOverride } : null, customKey: '', name: key.name, label: key.label ?? '', @@ -100,6 +102,7 @@ export function useApiKeyMutations(options: { async () => { const payload = { openaiClientProfileOverride: form.value.openaiClientProfileOverride, + xaiClientProfileOverride: form.value.xaiClientProfileOverride, name: form.value.name.trim(), label: form.value.label.trim() || null, groupIds: [...new Set(form.value.groupIds)], @@ -299,6 +302,7 @@ export function useApiKeyMutations(options: { function emptyForm(): ApiKeyFormValue { return { openaiClientProfileOverride: null, + xaiClientProfileOverride: null, customKey: '', name: '', label: '', diff --git a/frontend/src/views/settings/components/ClientProfileCard.vue b/frontend/src/views/settings/components/ClientProfileCard.vue new file mode 100644 index 000000000..ca79f7ed1 --- /dev/null +++ b/frontend/src/views/settings/components/ClientProfileCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts index de700ae4e..eb3f43a83 100644 --- a/frontend/src/views/settings/composables/useSettingsForm.ts +++ b/frontend/src/views/settings/composables/useSettingsForm.ts @@ -1,6 +1,6 @@ import type { rotationOptions } from '../constants' import type { RequestLocation } from '@/api' -import type { ClientProfileSelection } from '@/api/modules/client-profiles' +import type { ClientProfileSelection, XaiClientProfileSelection } from '@/api/modules/client-profiles' import { computed, reactive, ref, shallowRef } from 'vue' import { getSettings, updateSettings } from '@/api' @@ -23,6 +23,7 @@ export function useSettingsForm() { const savedRequestLocation = shallowRef() const form = reactive({ openaiClientProfile: null as ClientProfileSelection | null, + xaiClientProfile: null as XaiClientProfileSelection | null, requestLocationEnabled: false, requestLocation: { country: '', region: '', city: '', timezone: '' }, refreshMarginSeconds: null as number | null, @@ -118,6 +119,7 @@ export function useSettingsForm() { form.rotationStrategy = data.rotationStrategy form.minCodexDesktopVersion = data.minCodexDesktopVersion ?? '' form.openaiClientProfile = data.openaiClientProfile + form.xaiClientProfile = data.xaiClientProfile form.minCodexCliVersion = data.minCodexCliVersion ?? '' form.usageRetentionDays = data.usageRetentionDays form.opsEventRetentionDays = data.opsEventRetentionDays @@ -183,7 +185,7 @@ export function useSettingsForm() { } async function saveSettings() { - if (saving.value || loading.value || !savedRequestLocation.value || !form.openaiClientProfile) + if (saving.value || loading.value || !savedRequestLocation.value || !form.openaiClientProfile || !form.xaiClientProfile) return const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) { @@ -228,10 +230,12 @@ export function useSettingsForm() { toast.warning('探测模型名称不能超过 128 个字符') return } + const xaiClientProfile = form.xaiClientProfile const openaiClientProfile = form.openaiClientProfile await saveAction.run(async () => { const result = await updateSettings({ openaiClientProfile, + xaiClientProfile, requestLocationEnabled: form.requestLocationEnabled, requestLocation, modelMappings: mappingPayload(), diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index 28a7bd749..5fa9dff6d 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -4,15 +4,14 @@ import { computed, reactive, shallowRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import BaseButton from '@/components/base/BaseButton.vue' -import BaseCard from '@/components/base/BaseCard.vue' import BaseIconButton from '@/components/base/BaseIconButton.vue' import BasePageHeader from '@/components/base/BasePageHeader.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import BaseSelect from '@/components/base/BaseSelect.vue' -import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor.vue' import AccountAutoFreezeCard from './components/AccountAutoFreezeCard.vue' import SettingsBackupSection from './components/backup/SettingsBackupSection.vue' +import ClientProfileCard from './components/ClientProfileCard.vue' import ModelAliasesCard from './components/ModelAliasesCard.vue' import RequestLocationCard from './components/RequestLocationCard.vue' import RequestQueueCard from './components/RequestQueueCard.vue' @@ -170,9 +169,12 @@ watch(section, (value) => {
- - - +