feat(settings): unify OpenAI and xAI client identities (#174)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-19 17:39:29 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 6659d0c1f9
commit 1acd4a5f59
71 changed files with 1356 additions and 623 deletions
+2 -6
View File
@@ -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?;
+1 -4
View File
@@ -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 整体契约的根级测试场景。
+25 -38
View File
@@ -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) {
+1 -3
View File
@@ -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,
)),
@@ -87,6 +87,7 @@ pub struct ClientKeyListQuery {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -146,6 +147,7 @@ impl fmt::Debug for ClientKeySecret {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -158,6 +160,7 @@ pub struct CreateClientKey {
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -183,6 +186,7 @@ impl fmt::Debug for NewClientKey {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -18,6 +18,7 @@ pub use gateway_core::account::RotationStrategy;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
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<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
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,
}
}
}
@@ -57,7 +57,7 @@ pub trait ClientKeyService: Send + Sync {
}
pub(crate) struct DefaultClientKeyService {
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -67,12 +67,12 @@ impl DefaultClientKeyService {
pub(crate) fn new(
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
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<CreatedClientKey, AdminError> {
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<ClientKeyMutation, AdminError> {
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
@@ -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(
@@ -39,11 +39,13 @@ pub trait SettingsService: Send + Sync {
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
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<dyn crate::ports::pricing::PricingSource>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
fn profile_provider(
&self,
provider: &str,
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, 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<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> 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<gateway_core::account::OpaqueProviderData, AdminError> {
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<gateway_core::account::OpaqueProviderData, AdminError> {
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<RuntimeSettings, AdminError> {
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
@@ -991,6 +991,7 @@ impl SettingsStore for StaticSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -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,
@@ -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),
@@ -773,6 +773,7 @@ impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
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"),
@@ -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<gateway_admin::model::pricing::StoredPricing> {
@@ -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(),
@@ -148,6 +148,7 @@ impl ClientKeySort {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
name: String,
label: Option<String>,
@@ -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<Option<serde_json::Map<String, serde_json::Value>>>,
#[serde(default, deserialize_with = "deserialize_profile_override")]
xai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
id: String,
name: String,
label: Option<String>,
@@ -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<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
label: Option<String>,
@@ -358,6 +368,9 @@ impl From<ClientKeyRecord> 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,
@@ -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<String, String>;
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
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_json::Map<String, serde_json::Value>>,
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
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<RuntimeSettings> 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::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/openai",
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/openai/preview",
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
@@ -731,6 +740,7 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
@@ -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(
@@ -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::<UpdateClientKeyRequest>(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());
}
@@ -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"),
@@ -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::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body["openaiClientProfile"] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body["openaiClientProfile"] =
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(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::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body[field] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body[field] = json!({"versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
}
}
fn custom_pricing() -> Value {
@@ -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()),
@@ -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::<AdminStoreResult<ModelMappings>>()?;
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,
@@ -112,6 +112,7 @@ impl fmt::Debug for ClientApiKeySecret {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientApiKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -273,6 +274,7 @@ pub struct ClientApiKeyPage {
#[derive(Clone)]
pub struct NewClientApiKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -306,6 +308,7 @@ impl NewClientApiKey {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientApiKeyDetails {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -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::<Postgres>::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<Admin
fn admin_client_key_record(record: ClientApiKeyRecord) -> AdminStoreResult<AdminClientKeyRecord> {
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<ClientApiK
)
.map_err(|_| invalid("invalid request profile"))?
.map(|value| gateway_core::account::OpaqueProviderData::new(value.0)),
xai_client_profile_override: row
.try_get::<Option<sqlx::types::Json<serde_json::Map<String, serde_json::Value>>>, _>(
"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"))?,
@@ -20,6 +20,7 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
#[derive(Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub config_revision: Revision,
pub admin_api_key: Option<String>,
pub refresh_margin_seconds: u64,
@@ -110,6 +111,7 @@ impl fmt::Debug for RuntimeSettings {
#[derive(Clone)]
pub struct RuntimeSettingsUpdate {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub admin_api_key: Option<String>,
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<Runtime
.0
.remove("openai")
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile: row
.provider_request_profiles_json
.0
.remove("xai")
.map(gateway_core::account::OpaqueProviderData::new),
config_revision: Revision::new(to_u64(row.config_revision)?)?,
admin_api_key: row.admin_api_key,
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
@@ -171,6 +171,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: None,
weekly_limit_usd: None,
id: client_key_id("key_group_one"),
@@ -197,6 +198,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: None,
weekly_limit_usd: None,
id: client_key_id("key_group_one"),
@@ -321,6 +323,7 @@ fn new_key(id: &str, group_ids: Vec<AccountGroupId>) -> 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(),
@@ -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
@@ -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
@@ -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;
}
+4 -171
View File
@@ -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<CodexResidency>,
#[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<gateway_core::account::OpaqueProviderData, OpenAiConfigError> {
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<CodexResidency>,
pub verified_at: DateTime<Utc>,
}
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<CodexWireProfileConfig> 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()
}
+9 -7
View File
@@ -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::<u64>().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")]
@@ -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<Utc>,
}
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 {
+8 -18
View File
@@ -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"))
+15 -69
View File
@@ -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::<OpenAiConfig>(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()
}
@@ -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),
@@ -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]
@@ -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),
@@ -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()),
)
}
@@ -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,
@@ -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();
+55 -7
View File
@@ -321,6 +321,60 @@ impl XaiAdminProvider {
#[async_trait]
impl ProviderAdmin for XaiAdminProvider {
fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
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<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
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<DashboardWireProfile> {
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(),
@@ -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, XaiConfigError> {
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<Utc>,
}
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,
}
+14 -14
View File
@@ -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<ProviderBundle, XaiInitializeError> {
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<ProviderBundle, XaiInitializeError> {
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<dyn GrokEndpointPolicy> = 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")]
@@ -129,6 +129,23 @@ impl GrokBuildProvider {
#[async_trait]
impl Provider for GrokBuildProvider {
fn resolve_request_profile(
&self,
configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderError> {
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<XaiWireProfileState, ProviderError> {
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,
@@ -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<String>,
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<Self, ClientProfileError> {
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<OpaqueProviderData, ClientProfileError> {
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<XaiWireProfile, ClientProfileError> {
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<OpaqueProviderData, ClientProfileError> {
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<OpaqueProviderData, ClientProfileError> {
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;
@@ -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,
@@ -684,6 +684,8 @@ fn build_official_client(
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
let mut builder = Client::builder()
// 工作区可能同时启用 native-tls;与官方 Grok CLI 一样显式固定 rustls,避免握手画像漂移。
.use_rustls_tls()
.redirect(Policy::none())
.no_proxy()
.connect_timeout(CONNECT_TIMEOUT)
@@ -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<Utc>,
}
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<RwLock<XaiWireProfileConfig>>);
pub struct XaiWireProfileState(Arc<RwLock<XaiWireProfile>>);
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();
}
}
}
+67 -39
View File
@@ -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());
}
@@ -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::<XaiConfig>("{}").is_err());
assert!(serde_json::from_str::<XaiConfig>(
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());
}
@@ -1,5 +1,4 @@
mod admin;
mod config;
mod credential;
mod provider;
mod support;
@@ -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::<Vec<_>>()
.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)");
}
+8 -11
View File
@@ -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::<XaiWireProfile>(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(
@@ -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}"
);
}
}
@@ -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(
@@ -1,5 +1,6 @@
mod canonical;
mod catalog;
mod client_profile;
mod compaction;
mod config;
mod egress;
@@ -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<Vec<u8>> {
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 =
@@ -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<VecDeque<Result<String, GrokCliReleaseError>>>,
}
#[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<Item = Result<String, GrokCliReleaseError>>) -> 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(),
+8 -6
View File
@@ -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`;失败继续使用同组合的有效版本。
全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。
+2 -16
View File
@@ -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:
+31 -2
View File
@@ -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`
+6 -4
View File
@@ -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 重放完整历史;
+3 -1
View File
@@ -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
@@ -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<XaiClientProfileSelection, 'versionMode'> {
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<XaiClientProfilePreview>({
url: '/api/admin/settings/client-profiles/xai/preview',
method: 'POST',
data: { configuration },
silent: true,
})
}
+2 -1
View File
@@ -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
@@ -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)
</div>
</template>
</template>
<div class="grid min-h-20 min-w-0 content-start gap-2 rounded-cp bg-cp-fill-quaternary p-4" aria-live="polite" :aria-busy="previewing">
<p v-if="needsVersionInput" class="m-0 text-cp-sm text-cp-text-tertiary">
填写版本后预览
</p>
<div v-else-if="previewing" class="grid gap-2" role="status" aria-label="正在解析客户端身份">
<div class="flex h-lh items-center text-cp-sm" aria-hidden="true">
<BaseSkeleton shape="text" class="w-4/5" />
</div>
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
<BaseSkeleton shape="text" class="w-52 max-w-full" />
</div>
</div>
<p v-else-if="previewError" role="alert" class="m-0 text-cp-sm text-cp-error">
{{ previewError }}
</p>
<template v-else-if="preview">
<code class="break-all text-cp-sm text-cp-text">{{ preview.userAgent }}</code>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
{{ preview.versionSource === 'custom' ? '自定义版本' : '自动更新' }}
<template v-if="preview.versionSource === 'official'">
· {{ preview.checkedAt ? `检查于 ${formatDateTime(preview.checkedAt)}` : '待检查' }}
</template>
</p>
<p v-if="preview.error && preview.versionSource === 'official'" :title="preview.error" class="m-0 text-cp-sm text-cp-warning">
更新失败 · 沿用上次版本
</p>
</template>
</div>
<ClientProfilePreviewPanel :preview="preview" :previewing="previewing" :needs-version-input="needsVersionInput" :error="previewError" />
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { ClientProfilePreview } from '@/api/modules/client-profiles'
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
import { formatDateTime } from '@/utils/date'
defineProps<{
preview?: Pick<ClientProfilePreview, 'userAgent' | 'versionSource' | 'checkedAt' | 'error'>
previewing: boolean
needsVersionInput: boolean
error: string
}>()
</script>
<template>
<div class="grid min-h-20 min-w-0 content-center gap-2 rounded-cp bg-cp-fill-quaternary p-4" aria-live="polite" :aria-busy="previewing">
<p v-if="needsVersionInput" class="m-0 text-cp-sm text-cp-text-tertiary">
填写版本后预览
</p>
<div v-else-if="previewing" class="grid gap-2" role="status" aria-label="正在解析客户端身份">
<div class="flex h-lh items-center text-cp-sm" aria-hidden="true">
<BaseSkeleton shape="text" class="w-4/5" />
</div>
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
<BaseSkeleton shape="text" class="w-52 max-w-full" />
</div>
</div>
<p v-else-if="error" role="alert" class="m-0 text-cp-sm text-cp-error">
{{ error }}
</p>
<template v-else-if="preview">
<code class="break-all text-cp-sm text-cp-text">{{ preview.userAgent }}</code>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
{{ preview.versionSource === 'custom' ? '自定义版本' : '自动更新' }}
<template v-if="preview.versionSource === 'official'">
· {{ preview.checkedAt ? `检查于 ${formatDateTime(preview.checkedAt)}` : '待检查' }}
</template>
</p>
<p v-if="preview.error && preview.versionSource === 'official'" :title="preview.error" class="m-0 text-cp-sm text-cp-warning">
更新失败 · 沿用上次版本
</p>
</template>
</div>
</template>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import type { XaiClientProfilePreview, XaiClientProfileSelection } from '@/api/modules/client-profiles'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getXaiClientProfileOptions, previewXaiClientProfile } from '@/api/modules/client-profiles'
import BaseButton from '@/components/base/BaseButton.vue'
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 { errorMessage } from '@/utils/async'
import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue'
const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), {
active: true,
disabled: false,
allowInherit: false,
})
const model = defineModel<XaiClientProfileSelection | null>({ required: true })
const globalConfiguration = shallowRef<XaiClientProfileSelection>()
const preview = shallowRef<XaiClientProfilePreview>()
const loadError = shallowRef('')
const previewError = shallowRef('')
const previewing = shallowRef(false)
const needsVersionInput = computed(() => model.value?.versionMode === 'fixed' && !model.value.clientVersion)
const fields = [
{ key: 'clientIdentifier', label: '客户端标识' },
{ key: 'clientMode', label: '运行模式' },
{ key: 'targetOs', label: '操作系统' },
{ key: 'targetArch', label: 'CPU 架构' },
] as const
const source = computed({
get: () => model.value === null ? 'global' : 'independent',
set: (value: string) => {
if (value === 'global')
model.value = null
else if (globalConfiguration.value)
model.value = { ...globalConfiguration.value }
},
})
const versionMode = computed({
get: () => model.value?.versionMode ?? 'latest',
set: (value: string) => {
if (model.value) {
model.value = {
...model.value,
versionMode: value === 'fixed' ? 'fixed' : 'latest',
clientVersion: value === 'fixed' ? preview.value?.clientVersion ?? null : null,
}
}
},
})
function updateField(key: keyof XaiClientProfileSelection, value: string) {
if (model.value)
model.value = { ...model.value, [key]: key === 'clientVersion' ? value || null : value }
}
async function load() {
loadError.value = ''
try {
globalConfiguration.value = (await getXaiClientProfileOptions()).globalConfiguration
}
catch (error) {
loadError.value = errorMessage(error)
}
}
watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
let cancelled = false
preview.value = undefined
previewError.value = ''
previewing.value = active && !needsVersionInput.value
if (!active || needsVersionInput.value)
return
const timer = setTimeout(async () => {
try {
const result = await previewXaiClientProfile(configuration)
if (!cancelled)
preview.value = result
}
catch (error) {
if (!cancelled)
previewError.value = errorMessage(error)
}
finally {
if (!cancelled)
previewing.value = false
}
}, 250)
onCleanup(() => {
cancelled = true
clearTimeout(timer)
})
}, { immediate: true })
onMounted(() => props.allowInherit && void load())
</script>
<template>
<div class="grid min-w-0 gap-4">
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<span>全局配置加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
重试
</BaseButton>
</div>
<BaseSegmented
v-if="allowInherit"
v-model="source"
label="xAI 客户端身份来源"
class="justify-self-start"
:options="[{ label: '全局配置', value: 'global' }, { label: '独立配置', value: 'independent' }]"
:disabled="disabled || !globalConfiguration"
/>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端类型">
<BaseInput model-value="Grok CLI" readonly :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect v-model="versionMode" class="w-full" :disabled="disabled" :options="[{ label: '跟随最新版本', value: 'latest' }, { label: '自定义版本', value: 'fixed' }]" />
</BaseFormItem>
</div>
<div v-if="model.versionMode === 'fixed'" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="Grok CLI 版本" required>
<BaseInput :model-value="model.clientVersion ?? ''" :disabled="disabled" placeholder="例如:1.0.13" @update:model-value="updateField('clientVersion', $event)" />
</BaseFormItem>
<BaseFormItem v-for="field in fields" :key="field.key" :label="field.label" required>
<BaseInput :model-value="model[field.key]" :disabled="disabled" @update:model-value="updateField(field.key, $event)" />
</BaseFormItem>
</div>
</template>
<ClientProfilePreviewPanel :preview="preview" :previewing="previewing" :needs-version-input="needsVersionInput" :error="previewError" />
</div>
</template>
@@ -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')
/>
</BaseFormItem>
<BaseFormItem label="上游身份">
<BaseFormItem label="OpenAI 上游身份">
<ClientProfileEditor v-if="open" v-model="form.openaiClientProfileOverride" allow-inherit :disabled="saving" />
</BaseFormItem>
<BaseFormItem label="xAI 上游身份">
<XaiClientProfileEditor v-if="open" v-model="form.xaiClientProfileOverride" allow-inherit :disabled="saving" />
</BaseFormItem>
<div class="grid gap-6 sm:grid-cols-2">
<BaseFormItem label="日限额">
@@ -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<ReturnType<typeof getApiKeys>>['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: '',
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { ClientProfileSelection, XaiClientProfileSelection } from '@/api/modules/client-profiles'
import { Openai, Xai } from '@boxicons/vue'
import { shallowRef } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor.vue'
import XaiClientProfileEditor from '@/components/client-profile/XaiClientProfileEditor.vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
withDefaults(defineProps<{ active?: boolean, disabled?: boolean }>(), {
active: true,
disabled: false,
})
const openai = defineModel<ClientProfileSelection | null>('openai', { required: true })
const xai = defineModel<XaiClientProfileSelection | null>('xai', { required: true })
const provider = shallowRef('openai')
const providerOptions = [
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
]
</script>
<template>
<BaseCard>
<template #header>
<div class="flex max-w-6xl items-start justify-between gap-4">
<div class="min-w-0 pt-0.5">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance">
客户端身份
</h2>
<p class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty">
配置网关向上游声明的客户端类型、版本与请求头
</p>
</div>
<BaseSegmented
v-model="provider"
label="客户端身份平台"
class="w-20 shrink-0"
display="icon"
:options="providerOptions"
:disabled="disabled"
/>
</div>
</template>
<ClientProfileEditor
v-if="openai"
v-show="provider === 'openai'"
v-model="openai"
:active="active"
:disabled="disabled"
class="max-w-6xl"
/>
<XaiClientProfileEditor
v-if="xai"
v-show="provider === 'xai'"
v-model="xai"
:active="active"
:disabled="disabled"
class="max-w-6xl"
/>
</BaseCard>
</template>
@@ -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<RequestLocation>()
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(),
+7 -5
View File
@@ -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) => {
<div v-if="visited.has('upstream')" v-show="section === 'upstream'" class="grid min-w-0 gap-5">
<TokenRefreshCard v-model:refresh-margin-seconds="refreshMarginSecondsValue" v-model:refresh-concurrency="refreshConcurrencyValue" />
<BaseCard title="客户端身份" description="配置网关向上游声明的客户端类型、版本与请求头">
<ClientProfileEditor v-if="form.openaiClientProfile" v-model="form.openaiClientProfile" :active="section === 'upstream'" :disabled="disabled" class="max-w-6xl" />
</BaseCard>
<ClientProfileCard
v-model:openai="form.openaiClientProfile"
v-model:xai="form.xaiClientProfile"
:active="section === 'upstream'"
:disabled="disabled"
/>
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="disabled" />
<ModelAliasesCard
:mappings="mappings"