mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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?;
|
||||
|
||||
@@ -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 整体契约的根级测试场景。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`;失败继续使用同组合的有效版本。
|
||||
|
||||
全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 重放完整历史;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user