diff --git a/README.md b/README.md index e15c65047..5cf063465 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs) [![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit) -[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/app-layer-refactor-architecture.md) +[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/architecture.md) @@ -26,7 +26,7 @@ | 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 | | Provider | OpenAI OAuth、xAI OAuth,多账号隔离与自动刷新 | | 路由 | Client Key 平台绑定、Provider instance、模型映射与安全 fallback | -| 延续 | 客户端携带历史;Provider 原生 continuation 固定账号与连接 | +| 延续 | OpenAI native/replay continuation;xAI 客户端完整历史 | | 管理 | Client Key、账号、模型目录、设置、观测、系统与 OAuth | | 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 | @@ -47,21 +47,24 @@ sudo chown "$(id -u):10001" deploy/config.yaml chmod 0640 deploy/config.yaml ``` -生成并导出两个基础设施密码: +分别生成三个密码: ```bash -export CPR_DATABASE_PASSWORD="$(openssl rand -hex 24)" -export CPR_REDIS_PASSWORD="$(openssl rand -hex 24)" +openssl rand -hex 24 +openssl rand -hex 24 +openssl rand -hex 24 ``` -再生成一次管理员初始密码并写入 `deploy/config.yaml`: +把结果写入 `deploy/config.yaml`: -| 配置 | 约束 | -| ------------------------ | ------------------------ | -| `admin.default_password` | 至少 12 位,不能包含 `$` | +| 配置 | 约束 | +| -------------------------- | ---------------------------- | +| `store.database.password` | 48 位十六进制 | +| `store.redis.password` | 48 位十六进制 | +| `admin.default_password` | 至少 12 位,不能包含 `$` | -`CPR_DATABASE_PASSWORD` 与 `CPR_REDIS_PASSWORD` 必须是 48 位十六进制,并在每次 Compose -命令的 shell 中可用。 +PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外 +导出环境变量。 Linux 需要允许容器组写入运行目录: @@ -158,8 +161,6 @@ upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。 ## 文档 - [部署](deploy/README.md) -- [终态代码架构](docs/app-layer-refactor-architecture.md) -- [终态数据模型](docs/multi-provider-final-database.md) -- [重构执行报告](docs/app-layer-refactor-execution-report.md) +- [架构与数据边界](docs/architecture.md) - [Release](https://github.com/zyycn/codex-proxy-rs/releases) - [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d8b3f21e6..cf55f09da 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "jsonwebtoken", "p256", "reqwest", + "semver", "serde", "serde_json", "sha2 0.11.0", diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index bc2b67216..85a8428ff 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -17,6 +17,8 @@ pub struct GatewayConfig { api: gateway_api::ApiConfig, openai: provider_openai::OpenAiConfig, xai: provider_xai::XaiConfig, + #[serde(default, rename = "services")] + _compose_services: Option, } impl LoadableConfig for GatewayConfig { @@ -55,6 +57,7 @@ pub async fn run() -> Result<(), BootstrapError> { api, openai, xai, + _compose_services: _, } = config; let host = gateway_host::initialize(host).await?; diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 285e4b4c2..7784aa135 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -135,7 +135,7 @@ fn config_loader_should_reject_removed_fingerprint_section() { #[test] fn config_loader_should_reject_invalid_codex_core_version() { - assert_rejected(valid_config().replace("codex_version: '0.144.2'", "codex_version: 'latest'")); + assert_rejected(valid_config().replace("codex_version: '0.144.6'", "codex_version: 'latest'")); } #[test] @@ -154,7 +154,7 @@ fn config_loader_should_reject_zero_server_port() { #[test] fn config_loader_should_reject_invalid_desktop_profile_fields() { - assert_rejected(valid_config().replace("desktop_build: '5307'", "desktop_build: 'build'")); + assert_rejected(valid_config().replace("desktop_build: '5628'", "desktop_build: 'build'")); } fn assert_rejected(config: String) { @@ -164,11 +164,15 @@ fn assert_rejected(config: String) { fn valid_config() -> String { CONFIG_EXAMPLE .replacen( - "password: ''", - &format!("password: '{POSTGRES_PASSWORD}'"), + "password: &postgres_password ''", + &format!("password: &postgres_password '{POSTGRES_PASSWORD}'"), + 1, + ) + .replacen( + "password: &redis_password ''", + &format!("password: &redis_password '{REDIS_PASSWORD}'"), 1, ) - .replacen("password: ''", &format!("password: '{REDIS_PASSWORD}'"), 1) .replace( "default_password: ''", &format!("default_password: '{ADMIN_PASSWORD}'"), diff --git a/backend/crates/gateway-store/src/redis/oauth_pending.rs b/backend/crates/gateway-store/src/redis/oauth_pending.rs index b9802abe7..c20838340 100644 --- a/backend/crates/gateway-store/src/redis/oauth_pending.rs +++ b/backend/crates/gateway-store/src/redis/oauth_pending.rs @@ -8,8 +8,9 @@ use gateway_core::provider_ports::{ }; use gateway_core::routing::ProviderKind; use redis::{Script, aio::ConnectionManager}; +use sha2::{Digest as _, Sha256}; -use super::{MAX_REDIS_EXACT_INTEGER, namespace, resource_fingerprint}; +use super::{MAX_REDIS_EXACT_INTEGER, namespace}; use crate::{StoreError, StoreResult}; const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; @@ -21,20 +22,33 @@ end local clock = redis.call('TIME') local now_ms = (tonumber(clock[1]) * 1000) + math.floor(tonumber(clock[2]) / 1000) local expires_at_ms = now_ms + tonumber(ARGV[3]) +local expires_at_epoch_seconds = math.floor(expires_at_ms / 1000) redis.call('HSET', KEYS[1], 'owner_fingerprint', ARGV[1], - 'expires_at', expires_at_ms, + 'expires_at_epoch_seconds', expires_at_epoch_seconds, 'provider_payload', ARGV[2]) redis.call('PEXPIREAT', KEYS[1], expires_at_ms) return 1 "#; const TAKE_SCRIPT: &str = r#" +local function equal_bytes(left, right) + local different = math.abs(string.len(left) - string.len(right)) + local length = math.max(string.len(left), string.len(right)) + for index = 1, length do + local left_byte = string.byte(left, index) or 0 + local right_byte = string.byte(right, index) or 0 + if left_byte ~= right_byte then + different = different + 1 + end + end + return different == 0 +end local owner = redis.call('HGET', KEYS[1], 'owner_fingerprint') if owner == false then return {0, ''} end -if owner ~= ARGV[1] then +if not equal_bytes(owner, ARGV[1]) then return {-1, ''} end local payload = redis.call('HGET', KEYS[1], 'provider_payload') @@ -60,13 +74,13 @@ impl RedisOAuthPendingFlowRepository { }) } - fn key(&self, provider_kind: &ProviderKind, flow: &OAuthPendingBinding) -> StoreResult { - let fingerprint = resource_fingerprint("OAuth pending flow", flow.expose_to_store())?; - Ok(format!( + fn key(&self, provider_kind: &ProviderKind, flow: &OAuthPendingBinding) -> String { + let fingerprint = provider_scoped_fingerprint(provider_kind, flow); + format!( "{}:{}:{fingerprint}", self.namespace, provider_kind.as_str() - )) + ) } } @@ -76,11 +90,8 @@ impl OAuthPendingFlowPort for RedisOAuthPendingFlowRepository { flow: NewOAuthPendingFlow, ) -> futures::future::BoxFuture<'_, Result> { Box::pin(async move { - let key = self - .key(flow.provider_kind(), flow.flow()) - .map_err(|_| provider_invalid("encode OAuth pending key"))?; - let owner = resource_fingerprint("OAuth pending owner", flow.owner().expose_to_store()) - .map_err(|_| provider_invalid("encode OAuth pending owner"))?; + let key = self.key(flow.provider_kind(), flow.flow()); + let owner = provider_scoped_fingerprint(flow.provider_kind(), flow.owner()); let payload = serde_json::to_vec(&serde_json::Value::Object( flow.payload().expose_to_provider().clone(), )) @@ -114,11 +125,8 @@ impl OAuthPendingFlowPort for RedisOAuthPendingFlowRepository { owner: &'a OAuthPendingBinding, ) -> futures::future::BoxFuture<'a, Result> { Box::pin(async move { - let key = self - .key(provider_kind, flow) - .map_err(|_| provider_invalid("encode OAuth pending key"))?; - let owner = resource_fingerprint("OAuth pending owner", owner.expose_to_store()) - .map_err(|_| provider_invalid("encode OAuth pending owner"))?; + let key = self.key(provider_kind, flow); + let owner = provider_scoped_fingerprint(provider_kind, owner); let mut connection = self.connection.clone(); let (status, payload): (i64, Vec) = Script::new(TAKE_SCRIPT) .key(key) @@ -145,6 +153,17 @@ impl OAuthPendingFlowPort for RedisOAuthPendingFlowRepository { } } +fn provider_scoped_fingerprint( + provider_kind: &ProviderKind, + binding: &OAuthPendingBinding, +) -> String { + let mut digest = Sha256::new(); + digest.update(provider_kind.as_str().as_bytes()); + digest.update([0]); + digest.update(binding.expose_to_store().as_bytes()); + hex::encode(digest.finalize()) +} + fn ttl_millis(ttl: Duration) -> StoreResult { u64::try_from(ttl.as_millis()) .ok() diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs index ff58e02eb..dda0b33b1 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs @@ -55,7 +55,7 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() { seed_instance(&database.pool, "inst_openai_admin", "openai") .await .expect("seed OpenAI instance"); - let repository = PgProviderAccountRepository::new(database.pool); + let repository = PgProviderAccountRepository::new(database.pool.clone()); for id in ["acct_quota_a", "acct_quota_b", "acct_quota_empty"] { repository .insert_provider_account(account(id, &format!("user-{id}"))) @@ -103,6 +103,13 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() { 20 ); assert!(observations.iter().all(|value| value.observed_at.is_some())); + assert_eq!( + current_revision(&database.pool).await, + 1, + "quota observation is runtime state, not a global configuration mutation" + ); + + database.close().await; } #[tokio::test] @@ -729,6 +736,11 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() { assert_eq!(unchanged.0, "after refresh"); assert_eq!(unchanged.1["access_token"], "after-secret"); assert_eq!(unchanged.2, 2); + assert_eq!( + current_revision(&database.pool).await, + 1, + "credential refresh advances only credential_revision" + ); database.close().await; } diff --git a/backend/crates/gateway-store/tests/redis/oauth_pending.rs b/backend/crates/gateway-store/tests/redis/oauth_pending.rs index b9933ae93..1e4bfff6a 100644 --- a/backend/crates/gateway-store/tests/redis/oauth_pending.rs +++ b/backend/crates/gateway-store/tests/redis/oauth_pending.rs @@ -10,11 +10,12 @@ use gateway_core::{ }; use gateway_store::redis::RedisOAuthPendingFlowRepository; use redis::aio::ConnectionManager; +use sha2::{Digest as _, Sha256}; use uuid::Uuid; #[tokio::test] async fn codex_pending_flow_wrong_owner_does_not_consume_it() { - let Some(repository) = repository().await else { + let Some((repository, mut connection, namespace)) = repository().await else { return; }; let provider = ProviderKind::new("openai").expect("provider kind"); @@ -45,6 +46,35 @@ async fn codex_pending_flow_wrong_owner_does_not_consume_it() { .expect("store pending flow"), OAuthPendingPutOutcome::Stored ); + let fingerprint = scoped_fingerprint(&provider, &flow); + let key = format!("{namespace}:oauth-pending:v1:openai:{fingerprint}"); + let mut fields: Vec = redis::cmd("HKEYS") + .arg(&key) + .query_async(&mut connection) + .await + .expect("read pending fields"); + fields.sort(); + assert_eq!( + fields, + [ + "expires_at_epoch_seconds", + "owner_fingerprint", + "provider_payload", + ] + ); + let expires_at: i64 = redis::cmd("HGET") + .arg(&key) + .arg("expires_at_epoch_seconds") + .query_async(&mut connection) + .await + .expect("read pending expiry"); + assert!(expires_at > chrono::Utc::now().timestamp()); + let ttl: i64 = redis::cmd("PTTL") + .arg(&key) + .query_async(&mut connection) + .await + .expect("read pending TTL"); + assert!(ttl > 0 && ttl <= 60_000); assert_eq!( repository .take_if_owner(&provider, &flow, &other_owner) @@ -68,14 +98,52 @@ async fn codex_pending_flow_wrong_owner_does_not_consume_it() { ); } -async fn repository() -> Option> { +#[tokio::test] +async fn same_raw_flow_is_scoped_by_provider_kind() { + let Some((repository, _connection, _namespace)) = repository().await else { + return; + }; + let flow = + OAuthPendingBinding::try_new(format!("flow-{}", Uuid::new_v4())).expect("flow binding"); + let owner = OAuthPendingBinding::try_new("owner").expect("owner binding"); + for provider_name in ["openai", "xai"] { + let provider = ProviderKind::new(provider_name).expect("provider kind"); + assert_eq!( + repository + .put_if_absent( + NewOAuthPendingFlow::try_new( + provider, + flow.clone(), + owner.clone(), + Duration::from_secs(60), + OpaqueProviderData::new(serde_json::Map::new()), + ) + .expect("pending flow"), + ) + .await + .expect("store provider-scoped pending flow"), + OAuthPendingPutOutcome::Stored + ); + } +} + +async fn repository() -> Option<( + Arc, + ConnectionManager, + String, +)> { let redis_url = std::env::var("CPR_TEST_REDIS_URL").ok()?; let client = redis::Client::open(redis_url).ok()?; let connection: ConnectionManager = client.get_connection_manager().await.ok()?; - RedisOAuthPendingFlowRepository::new( - connection, - &format!("gateway-store-test-{}", Uuid::new_v4()), - ) - .ok() - .map(Arc::new) + let namespace = format!("gateway-store-test-{}", Uuid::new_v4()); + let repository = RedisOAuthPendingFlowRepository::new(connection.clone(), &namespace).ok()?; + Some((Arc::new(repository), connection, namespace)) +} + +fn scoped_fingerprint(provider: &ProviderKind, flow: &OAuthPendingBinding) -> String { + let mut digest = Sha256::new(); + digest.update(provider.as_str().as_bytes()); + digest.update([0]); + digest.update(flow.expose_to_store().as_bytes()); + hex::encode(digest.finalize()) } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index a1b80ca0c..4a8e59b24 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -24,7 +24,10 @@ use crate::credential::{ CodexOAuthAdminService, CodexSignedIdentityVerifier, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, }; -use crate::transport::profile::{CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport}; +use crate::transport::profile::{ + CodexCliReleaseService, CodexDesktopReleaseService, OfficialCodexCliReleaseTransport, + OfficialCodexDesktopReleaseTransport, +}; use crate::transport::{CodexWebSocketPool, build_reqwest_client}; pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError}; @@ -73,6 +76,13 @@ pub async fn initialize( ), )); let desktop_release_status = desktop_release.status(); + let cli_release = Arc::new(CodexCliReleaseService::new( + profile.clone(), + Arc::new( + OfficialCodexCliReleaseTransport::new() + .map_err(|_| OpenAiInitializeError::DesktopRelease)?, + ), + )); let repository = CodexCredentialRepository::new(Arc::clone(&accounts)); let catalog = Arc::new(CodexCredentialCatalogService::new( repository.clone(), @@ -165,6 +175,7 @@ pub async fn initialize( refresh, quota, catalog, + cli_release, desktop_release, instances, provider_kind, diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 9b372b584..48835730e 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -53,7 +53,8 @@ use crate::transport::diagnostics::{ CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase, }; use crate::transport::profile::{ - APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState, + APPCAST_POLL_INTERVAL, CodexCliReleaseService, CodexDesktopReleaseService, + CodexWireProfileState, }; use crate::transport::protocol::responses::{CodexResponsesRequest, PreviousResponseScope}; use crate::transport::request::{ @@ -1621,12 +1622,14 @@ const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60); const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30); const QUOTA_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const DESKTOP_RELEASE_WORKER_OWNER: &str = "openai-desktop-release"; +const CLI_RELEASE_WORKER_OWNER: &str = "openai-cli-release"; const MODEL_ETAG_WORKER_OWNER: &str = "openai-model-etag"; pub(crate) fn worker_contributions( refresh: Arc, quota: Arc, catalog: Arc, + cli_release: Arc, desktop_release: Arc, instances: Arc, provider_kind: ProviderKind, @@ -1636,6 +1639,8 @@ pub(crate) fn worker_contributions( let etag_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_ETAG_WORKER_OWNER)?; let desktop_release_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, DESKTOP_RELEASE_WORKER_OWNER)?; + let cli_release_id = + WorkerId::try_new(WorkerKind::QuotaCatalogHealth, CLI_RELEASE_WORKER_OWNER)?; Ok(vec![ WorkerContribution::Registration(scheduled_registration( refresh_id, @@ -1665,6 +1670,13 @@ pub(crate) fn worker_contributions( }), }, )?), + WorkerContribution::Registration(scheduled_registration( + cli_release_id, + APPCAST_POLL_INTERVAL, + Box::new(OpenAiCliReleaseTask { + service: cli_release, + }), + )?), WorkerContribution::Registration(scheduled_registration( desktop_release_id, APPCAST_POLL_INTERVAL, @@ -1751,6 +1763,27 @@ struct OpenAiDesktopReleaseTask { service: Arc, } +struct OpenAiCliReleaseTask { + service: Arc, +} + +impl ScheduledTask for OpenAiCliReleaseTask { + fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { + Box::pin(async move { + let refresh = self.service.refresh(); + tokio::pin!(refresh); + let result = tokio::select! { + () = context.cancellation().cancelled() => return Ok(()), + result = &mut refresh => result, + }; + if let Err(error) = result { + tracing::warn!(error = %error, "OpenAI CLI release check failed"); + } + Ok(()) + }) + } +} + impl ScheduledTask for OpenAiDesktopReleaseTask { fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { Box::pin(async move { diff --git a/backend/crates/providers/openai/src/transport/profile.rs b/backend/crates/providers/openai/src/transport/profile.rs index f97fc242c..a54e6b840 100644 --- a/backend/crates/providers/openai/src/transport/profile.rs +++ b/backend/crates/providers/openai/src/transport/profile.rs @@ -13,6 +13,7 @@ use url::Url; /// Codex Desktop 官方 appcast 地址。 pub const CODEX_DESKTOP_APPCAST_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; +pub const CODEX_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest"; /// 官方制品画像检查周期。 pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); @@ -96,6 +97,112 @@ impl CodexWireProfileState { profile.desktop_version = desktop_version.to_owned(); profile.desktop_build = desktop_build.to_owned(); } + + /// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时 Core 画像。 + pub fn update_cli_release(&self, codex_version: &str) { + let mut profile = self + .profile + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if profile.codex_version != codex_version { + profile.codex_version = codex_version.to_owned(); + } + } +} + +pub trait CodexCliReleaseTransport: Send + Sync { + fn fetch(&self) -> BoxFuture<'_, Result>; +} + +#[derive(Clone)] +pub struct OfficialCodexCliReleaseTransport { + client: Client, + endpoint: Url, +} + +impl OfficialCodexCliReleaseTransport { + pub fn new() -> Result { + let endpoint = + Url::parse(CODEX_CLI_RELEASE_URL).map_err(|_| CodexCliReleaseError::InvalidEndpoint)?; + let client = Client::builder() + .https_only(true) + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(APPCAST_TIMEOUT) + .build() + .map_err(|_| CodexCliReleaseError::ClientInitialization)?; + Ok(Self { client, endpoint }) + } +} + +impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport { + fn fetch(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response = self.client.get(self.endpoint.clone()).send().await?; + if !response.status().is_success() { + return Err(CodexCliReleaseError::HttpStatus(response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|size| size > 64 * 1024) + { + return Err(CodexCliReleaseError::ResponseTooLarge); + } + let bytes = response.bytes().await?; + if bytes.len() > 64 * 1024 { + return Err(CodexCliReleaseError::ResponseTooLarge); + } + let document: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| CodexCliReleaseError::InvalidDocument)?; + let version = document + .get("version") + .and_then(serde_json::Value::as_str) + .ok_or(CodexCliReleaseError::InvalidDocument)?; + semver::Version::parse(version).map_err(|_| CodexCliReleaseError::InvalidVersion)?; + Ok(version.to_owned()) + }) + } +} + +#[derive(Clone)] +pub struct CodexCliReleaseService { + transport: Arc, + profile: CodexWireProfileState, +} + +impl CodexCliReleaseService { + #[must_use] + pub fn new( + profile: CodexWireProfileState, + transport: Arc, + ) -> Self { + Self { transport, profile } + } + + pub async fn refresh(&self) -> Result { + let version = self.transport.fetch().await?; + self.profile.update_cli_release(&version); + Ok(version) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CodexCliReleaseError { + #[error("Codex CLI release client initialization failed")] + ClientInitialization, + #[error("Codex CLI release endpoint is invalid")] + InvalidEndpoint, + #[error("Codex CLI release request failed")] + Request(#[from] reqwest::Error), + #[error("Codex CLI release endpoint returned HTTP {0}")] + HttpStatus(u16), + #[error("Codex CLI release response exceeded the size limit")] + ResponseTooLarge, + #[error("Codex CLI release document is invalid")] + InvalidDocument, + #[error("Codex CLI release version is invalid")] + InvalidVersion, } /// 官方 appcast 中按顺序出现的首个完整 Desktop 制品。 diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 56080e106..812f1ea85 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use chrono::{DateTime, TimeZone as _, Utc}; -use futures::future::BoxFuture; +use futures::{StreamExt as _, future::BoxFuture}; use gateway_admin::model::accounts::{ AccountAvailability as AdminAccountAvailability, AccountRecord, }; @@ -20,13 +20,21 @@ use gateway_admin::model::provider_credentials::{ use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::ProviderAdminErrorKind; use gateway_core::engine::credential::{ - CredentialRevision, OpaqueProviderData, ProviderAccount, ProviderAccountId, - ProviderAccountStore, + AccountAvailability, AccountSelectionPolicy, CredentialRevision, NewProviderAccount, + OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId, + ProviderAccountStore, RotationStrategy, }; +use gateway_core::engine::provider::ProviderRequest; +use gateway_core::engine::{ + AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext, +}; +use gateway_core::event::GatewayEvent; +use gateway_core::operation::OperationKind; use gateway_core::operation::{ ContentPart, GenerateRequest, Message, MessageRole, Operation, ProviderOptions, ReasoningEffort, ReasoningRequirement, }; +use gateway_core::policy::ClientApiKeyId; use gateway_core::provider_ports::{ NewOAuthPendingFlow, OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingTakeOutcome, ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogPorts, ProviderCooldown, @@ -34,15 +42,165 @@ use gateway_core::provider_ports::{ ProviderInstanceCatalogPort, ProviderInstanceConfig, ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStorePorts, }; -use gateway_core::routing::{ProviderInstanceId, ProviderKind, UpstreamModelId}; +use gateway_core::routing::{ + ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId, + ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId, +}; use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable}; use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig}; use provider_openai::credential::CreateCodexCredential; use provider_openai::transport::profile::APPCAST_POLL_INTERVAL; use serde_json::{Map, Value, json}; +use std::collections::BTreeSet; use crate::support::{MemoryAccountStore, TestLeaseCoordinator, instance_id, profile, secret}; +#[tokio::test] +#[ignore = "requires CODEX_REAL_ACCOUNT_FIXTURE and consumes live OpenAI quota"] +async fn real_openai_conversation_crosses_production_provider_boundaries() { + let fixture = std::env::var("CODEX_REAL_ACCOUNT_FIXTURE") + .expect("CODEX_REAL_ACCOUNT_FIXTURE must point to a CPR JSON document"); + let payload = serde_json::from_slice::(&std::fs::read(fixture).expect("read fixture")) + .expect("parse fixture"); + let Value::Object(document) = payload else { + panic!("real OpenAI fixture must be an object"); + }; + let store = Arc::new(MemoryAccountStore::default()); + let bundle = provider_openai::initialize( + valid_config(), + provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + ) + .await + .expect("OpenAI bundle"); + let prepared = bundle + .admin_provider() + .prepare_import(PrepareCredentialImport { + provider_instance_id: instance_id(), + document: ProviderDocument::new(OpaqueProviderData::new(document)), + }) + .await + .expect("prepare real OpenAI import"); + for credential in prepared.credentials { + store + .create_account(prepared_account(credential)) + .await + .expect("seed verified OpenAI account"); + } + + let instance = ProviderInstance::new( + instance_id(), + ProviderKind::new("openai").expect("provider"), + "https://chatgpt.com/backend-api".to_owned(), + true, + InstanceHealth::Healthy, + ); + let models = bundle + .core_provider() + .query_model_capabilities(&instance) + .await + .expect("query live OpenAI catalog"); + let model = models.first().expect("one live generation model"); + let operation = bundle + .admin_provider() + .connection_test_operation(model.upstream_model(), "Reply with exactly CPR_REAL_OK.") + .expect("real connection operation"); + let request = planned_real_request(instance, model.upstream_model().clone(), operation); + let mut stream = bundle + .core_provider() + .execute(request, real_attempt_context()) + .await + .expect("prepare real OpenAI stream"); + let mut started = false; + let mut completed = false; + let mut usage = false; + let mut text = false; + while let Some(event) = stream.next().await { + for fact in event.expect("real OpenAI event").canonical_facts() { + match fact { + GatewayEvent::Started(_) => started = true, + GatewayEvent::TextDelta(_) => text = true, + GatewayEvent::Usage(_) => usage = true, + GatewayEvent::Completed(_) => completed = true, + _ => {} + } + } + } + assert!(started && text && usage && completed); +} + +fn prepared_account( + value: gateway_admin::model::provider_credentials::PreparedCredentialCreate, +) -> NewProviderAccount { + let account = ProviderAccount::new( + value.account_id, + value.provider_instance_id, + value.provider_kind, + value.name, + value.upstream_user_id, + CredentialRevision::new(1).expect("initial revision"), + value.access_token_expires_at.into(), + ) + .with_profile(value.email, value.upstream_account_id, value.plan_type) + .with_runtime_state(value.enabled, AccountAvailability::Ready, None) + .with_refresh_schedule( + value.has_refresh_token, + value.next_refresh_at.map(Into::into), + ); + NewProviderAccount { + account, + credential: PlaintextCredential::new( + value.provider_material.into_provider_data().into_inner(), + ), + } +} + +fn planned_real_request( + instance: ProviderInstance, + upstream_model: UpstreamModelId, + operation: Operation, +) -> ProviderRequest { + let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model"); + let snapshot = RuntimeSnapshot::new( + ConfigRevision::new(1).expect("revision"), + AccountSelectionPolicy::new( + RotationStrategy::Smart, + NonZeroU32::new(1).expect("concurrency"), + Duration::ZERO, + ), + vec![instance.clone()], + vec![ProviderModel::new( + instance.id().clone(), + upstream_model, + ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), 1_000_000, None), + )], + vec![], + ) + .expect("snapshot"); + let plan = snapshot + .plan(&public_model, &operation, &RoutingContext::default()) + .expect("route plan"); + ProviderRequest::new(operation, plan.candidates()[0].clone()) +} + +fn real_attempt_context() -> AttemptContext { + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_real_openai_conversation").expect("request"), + ClientApiKeyId::new("real_test_client").expect("client"), + ), + NonZeroU32::new(1).expect("attempt"), + SystemTime::now() + Duration::from_secs(90), + AccountSelectionPolicy::new( + RotationStrategy::Smart, + NonZeroU32::new(1).expect("concurrency"), + Duration::ZERO, + ), + AccountAttemptContext::new(BTreeSet::new(), None, None), + None, + CancellationToken::new(), + ) +} + #[tokio::test] async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() { let mut bundle = provider_openai::initialize(valid_config(), provider_ports()) @@ -52,7 +210,7 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions assert_eq!(bundle.core_provider().name(), "openai"); assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai"); let contributions = bundle.take_worker_contributions(); - assert_eq!(contributions.len(), 4); + assert_eq!(contributions.len(), 5); assert!( contributions .iter() diff --git a/backend/crates/providers/xai/Cargo.toml b/backend/crates/providers/xai/Cargo.toml index f02619509..73936ceeb 100644 --- a/backend/crates/providers/xai/Cargo.toml +++ b/backend/crates/providers/xai/Cargo.toml @@ -21,6 +21,7 @@ jsonwebtoken = { version = "=10.3.0", default-features = false, features = ["rus reqwest = { version = "=0.12.28", default-features = false, features = ["stream", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +semver = "1.0.27" sha2 = "0.11.0" thiserror = "2.0.18" tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "sync", "time"] } diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 8d645d1e4..dbac73e4f 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -49,6 +49,7 @@ use sha2::{Digest as _, Sha256}; use url::Url; use uuid::Uuid; +use crate::XaiWireProfileState; use crate::credential::{ AuthorizationCallback, FailureClass, GrokAccountProfile, GrokCredentialAdmin, GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService, @@ -59,17 +60,15 @@ use crate::credential::{ PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist, RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet, }; -use crate::transport::{ - GROK_CLI_BASE_URL, GROK_CLIENT_IDENTIFIER, GROK_CLIENT_MODE, GROK_CLIENT_VERSION, - GROK_TARGET_ARCH, GROK_TARGET_OS, XAI_PROVIDER_NAME, grok_billing_breakdown, -}; +use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown}; const PENDING_SCHEMA_VERSION: u64 = 1; -const PENDING_TTL: TimeDelta = TimeDelta::minutes(10); +const PENDING_TTL: TimeDelta = TimeDelta::minutes(30); const MAX_PENDING_TEXT_BYTES: usize = 512; pub(crate) struct XaiAdminProvider { provider_kind: ProviderKind, + wire_profile: XaiWireProfileState, accounts: Arc, repository: GrokCredentialRepository, oauth_config: GrokOAuthConfig, @@ -96,11 +95,13 @@ impl XaiAdminProvider { #[must_use] pub(crate) fn new( provider_kind: ProviderKind, + wire_profile: XaiWireProfileState, accounts: Arc, services: XaiAdminServices, ) -> Self { Self { provider_kind, + wire_profile, accounts, repository: services.repository, oauth_config: services.oauth_config, @@ -221,32 +222,30 @@ impl ProviderAdmin for XaiAdminProvider { Some(DashboardWireProfile { provider: self.provider_kind.as_str().to_owned(), product: "Grok Build".to_owned(), - version: GROK_CLIENT_VERSION.to_owned(), + version: self.wire_profile.client_version(), build: None, target: DashboardWireTarget { - os_type: GROK_TARGET_OS.to_owned(), + os_type: self.wire_profile.target_os(), os_version: "—".to_owned(), - arch: GROK_TARGET_ARCH.to_owned(), - terminal: GROK_CLIENT_MODE.to_owned(), + arch: self.wire_profile.target_arch(), + terminal: self.wire_profile.client_mode(), }, - user_agent: format!( - "{GROK_CLIENT_IDENTIFIER}/{GROK_CLIENT_VERSION} ({GROK_TARGET_OS}; {GROK_TARGET_ARCH})" - ), + user_agent: self.wire_profile.user_agent(), attributes: vec![ DashboardWireAttribute { label: "客户端标识".to_owned(), - value: GROK_CLIENT_IDENTIFIER.to_owned(), + value: self.wire_profile.client_identifier(), }, DashboardWireAttribute { label: "运行模式".to_owned(), - value: GROK_CLIENT_MODE.to_owned(), + value: self.wire_profile.client_mode(), }, DashboardWireAttribute { label: "Token 认证".to_owned(), value: "xai-grok-cli".to_owned(), }, ], - verified_at: None, + verified_at: Some(self.wire_profile.verified_at()), release: None, }) } @@ -601,7 +600,7 @@ impl ProviderAdmin for XaiAdminProvider { let catalog = if refresh { Some( self.catalog - .refresh_account_catalog(account_id, GROK_CLIENT_VERSION) + .refresh_account_catalog(account_id) .await .map_err(map_catalog_error)?, ) diff --git a/backend/crates/providers/xai/src/config.rs b/backend/crates/providers/xai/src/config.rs index 12b087715..21adb7a80 100644 --- a/backend/crates/providers/xai/src/config.rs +++ b/backend/crates/providers/xai/src/config.rs @@ -1,30 +1,78 @@ -//! xAI Provider 启动配置。 +//! xAI Provider 启动配置与 Grok CLI 请求画像校验。 use std::path::Path; +use chrono::{DateTime, Utc}; use serde::Deserialize; -use crate::{GrokOAuthConfig, transport::GROK_CLIENT_VERSION}; +use crate::{GrokOAuthConfig, XaiWireProfileState}; -/// xAI 的协议端点、client 与版本均为经过校验的官方固定事实。 -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +/// xAI Provider 唯一启动配置。 +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] -pub struct XaiConfig {} +pub struct XaiConfig { + pub wire_profile: XaiWireProfileConfig, +} impl XaiConfig { - /// 校验 Provider-owned 固定协议配置;当前没有相对路径字段。 + /// 校验 Provider-owned 字段;当前配置不含文件系统路径。 pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), XaiConfigError> { + self.wire_profile.validate()?; self.oauth_config().map(|_| ()) } - pub(crate) fn oauth_config(self) -> Result { - GrokOAuthConfig::official(GROK_CLIENT_VERSION).map_err(|_| XaiConfigError::InvalidProtocol) + #[must_use] + pub fn wire_profile_state(&self) -> XaiWireProfileState { + XaiWireProfileState::new(self.wire_profile.clone()) + } + + pub(crate) fn oauth_config(&self) -> Result { + GrokOAuthConfig::official().map_err(|_| XaiConfigError::InvalidProtocol) } } -/// xAI 固定协议配置无法构造。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +/// 经参考实现核验的 Grok CLI 请求画像。 +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct XaiWireProfileConfig { + pub client_identifier: String, + pub client_version: String, + pub client_mode: String, + pub target_os: String, + pub target_arch: String, + pub verified_at: DateTime, +} + +impl XaiWireProfileConfig { + fn validate(&self) -> Result<(), XaiConfigError> { + for (field, value) in [ + ( + "xai.wire_profile.client_identifier", + self.client_identifier.as_str(), + ), + ( + "xai.wire_profile.client_version", + self.client_version.as_str(), + ), + ("xai.wire_profile.client_mode", self.client_mode.as_str()), + ("xai.wire_profile.target_os", self.target_os.as_str()), + ("xai.wire_profile.target_arch", self.target_arch.as_str()), + ] { + if value.is_empty() + || value.len() > 64 + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(XaiConfigError::InvalidField(field)); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum XaiConfigError { + #[error("xAI configuration field is invalid: {0}")] + InvalidField(&'static str), #[error("xAI official protocol configuration is invalid")] InvalidProtocol, } diff --git a/backend/crates/providers/xai/src/credential/catalog.rs b/backend/crates/providers/xai/src/credential/catalog.rs index d23cf3bb0..782370265 100644 --- a/backend/crates/providers/xai/src/credential/catalog.rs +++ b/backend/crates/providers/xai/src/credential/catalog.rs @@ -19,7 +19,7 @@ use tokio::sync::Mutex; use super::repository::{GrokCredentialRepository, LoadedGrokCredential}; use super::types::{GrokCredentialAvailability, UpdateGrokCredentialState}; -use crate::transport::GROK_CLIENT_VERSION; +use crate::XaiWireProfileState; use crate::transport::catalog::{MAX_CATALOG_MODELS, valid_model_slug, validate_etag}; use crate::{ GrokBillingClient, GrokBillingTransport, GrokBuildProvider, GrokCatalogModel, @@ -193,6 +193,7 @@ pub struct GrokCredentialQuotaService { repository: GrokCredentialRepository, client: Arc, scheduling: GrokQuotaSchedulingProjection, + wire_profile: XaiWireProfileState, } #[derive(Clone, Default)] @@ -362,11 +363,13 @@ impl GrokCredentialQuotaService { pub fn new( repository: GrokCredentialRepository, transport: Arc, + wire_profile: XaiWireProfileState, ) -> Self { Self { repository, client: Arc::new(GrokBillingClient::new(transport)), scheduling: GrokQuotaSchedulingProjection::default(), + wire_profile, } } @@ -429,7 +432,7 @@ impl GrokCredentialQuotaService { { return Err(GrokQuotaError::AccountUnavailable); } - let session = billing_session(&loaded)?; + let session = billing_session(&loaded, &self.wire_profile)?; let billing = self .client .fetch(&session) @@ -865,6 +868,7 @@ pub struct GrokCredentialCatalogService { client: Arc, cache: Arc, published: Arc>, + wire_profile: XaiWireProfileState, } #[derive(Default)] @@ -879,12 +883,14 @@ impl GrokCredentialCatalogService { repository: GrokCredentialRepository, transport: Arc, cache: Arc, + wire_profile: XaiWireProfileState, ) -> Self { Self { repository, client: Arc::new(GrokModelCatalogClient::new(transport)), cache, published: Arc::new(RwLock::new(PublishedCatalogState::default())), + wire_profile, } } @@ -902,10 +908,10 @@ impl GrokCredentialCatalogService { access_token: SecretValue, user_id: SecretValue, email: Option, - client_version: impl Into, ) -> Result { - let session = GrokModelCatalogSession::new(access_token, user_id, email, client_version) - .map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?; + let session = + GrokModelCatalogSession::new(access_token, user_id, email, self.wire_profile.clone()) + .map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?; let snapshot = self .client .fetch(&session) @@ -935,7 +941,6 @@ impl GrokCredentialCatalogService { pub async fn refresh_account_catalog( &self, account_id: &ProviderAccountId, - client_version: impl Into, ) -> Result { let candidate = self .repository @@ -954,7 +959,6 @@ impl GrokCredentialCatalogService { .account .email() .map(|email| SecretValue::new(email.to_owned())), - client_version, ) .await?; let catalog = GrokAccountCatalog::new(account_id.clone(), revision, Utc::now(), seed); @@ -1019,11 +1023,11 @@ impl GrokCredentialCatalogService { return Err(GrokCredentialCatalogError::NoEligibleCredential); } - let client_version = instance_config.client_version().to_owned(); + let wire_profile = self.wire_profile.clone(); let mut fetched = stream::iter(candidates.into_iter().map(|candidate| { let client = Arc::clone(&self.client); - let client_version = client_version.clone(); - async move { fetch_candidate_catalog(client, candidate, client_version).await } + let wire_profile = wire_profile.clone(); + async move { fetch_candidate_catalog(client, candidate, wire_profile).await } })) .buffer_unordered(MAX_CONCURRENT_CATALOG_REQUESTS) .try_collect::>() @@ -1122,7 +1126,7 @@ fn eligible_catalog_candidate(candidate: &LoadedGrokCredential) -> bool { async fn fetch_candidate_catalog( client: Arc, candidate: LoadedGrokCredential, - client_version: String, + wire_profile: XaiWireProfileState, ) -> Result { let session = GrokModelCatalogSession::new( candidate.access_token, @@ -1131,7 +1135,7 @@ async fn fetch_candidate_catalog( .account .email() .map(|value| SecretValue::new(value.to_owned())), - client_version, + wire_profile, ) .map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?; let snapshot = client @@ -1171,6 +1175,7 @@ fn strict_model_union( fn billing_session( loaded: &LoadedGrokCredential, + wire_profile: &XaiWireProfileState, ) -> Result { GrokModelCatalogSession::new( loaded.access_token.clone(), @@ -1179,7 +1184,7 @@ fn billing_session( .account .email() .map(|value| SecretValue::new(value.to_owned())), - GROK_CLIENT_VERSION, + wire_profile.clone(), ) .map_err(|_| GrokQuotaError::InvalidData) } diff --git a/backend/crates/providers/xai/src/credential/client.rs b/backend/crates/providers/xai/src/credential/client.rs index 3dd556c7d..ec774365e 100644 --- a/backend/crates/providers/xai/src/credential/client.rs +++ b/backend/crates/providers/xai/src/credential/client.rs @@ -13,6 +13,7 @@ use crate::{ #[derive(Clone)] pub struct GrokOAuthClient { config: GrokOAuthConfig, + wire_profile: crate::XaiWireProfileState, transport: Arc, verifier: Arc, } @@ -22,11 +23,13 @@ impl GrokOAuthClient { #[must_use] pub fn new( config: GrokOAuthConfig, + wire_profile: crate::XaiWireProfileState, transport: Arc, verifier: Arc, ) -> Self { Self { config, + wire_profile, transport, verifier, } @@ -268,7 +271,7 @@ impl GrokOAuthClient { } fn version_header(&self) -> HttpHeader { - HttpHeader::new("x-grok-client-version", self.config.client_version()) + HttpHeader::new("x-grok-client-version", self.wire_profile.client_version()) } } diff --git a/backend/crates/providers/xai/src/credential/config.rs b/backend/crates/providers/xai/src/credential/config.rs index 72b241c3b..f450be47e 100644 --- a/backend/crates/providers/xai/src/credential/config.rs +++ b/backend/crates/providers/xai/src/credential/config.rs @@ -85,7 +85,6 @@ impl RedirectUriAllowlist { #[derive(Clone)] pub struct GrokOAuthConfig { issuer: Url, - client_version: String, } impl GrokOAuthConfig { @@ -93,24 +92,10 @@ impl GrokOAuthConfig { /// /// # Errors /// - /// Returns [`ConfigError::InvalidClientVersion`] when the version cannot be - /// safely emitted as a low-cardinality HTTP header. - pub fn official(client_version: impl Into) -> Result { - let client_version = client_version.into(); - if client_version.is_empty() - || client_version.len() > 64 - || !client_version - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) - { - return Err(ConfigError::InvalidClientVersion); - } - + /// Returns an error when the fixed official issuer cannot be constructed. + pub fn official() -> Result { let issuer = Url::parse(OFFICIAL_ISSUER).map_err(|_| ConfigError::UntrustedIssuer)?; - Ok(Self { - issuer, - client_version, - }) + Ok(Self { issuer }) } /// Returns the fixed issuer URL. @@ -131,12 +116,6 @@ impl GrokOAuthConfig { OFFICIAL_SCOPES } - /// 返回已配置的官方客户端版本请求头。 - #[must_use] - pub fn client_version(&self) -> &str { - &self.client_version - } - pub(crate) fn scope_string(&self) -> String { OFFICIAL_SCOPES.join(" ") } @@ -184,7 +163,6 @@ impl fmt::Debug for GrokOAuthConfig { .field("issuer", &self.issuer) .field("client_id", &OFFICIAL_CLIENT_ID) .field("scopes", &OFFICIAL_SCOPES) - .field("client_version", &self.client_version) .finish() } } diff --git a/backend/crates/providers/xai/src/credential/error.rs b/backend/crates/providers/xai/src/credential/error.rs index a8e756cc2..d0e648283 100644 --- a/backend/crates/providers/xai/src/credential/error.rs +++ b/backend/crates/providers/xai/src/credential/error.rs @@ -54,9 +54,6 @@ pub enum FailureClass { /// Configuration failures detected before any network request. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum ConfigError { - /// Client version is empty, non-ASCII, or contains unsafe characters. - #[error("invalid Grok client version")] - InvalidClientVersion, /// Redirect URI is malformed, insecure, or contains forbidden components. #[error("invalid OAuth redirect URI")] InvalidRedirectUri, diff --git a/backend/crates/providers/xai/src/credential/refresh.rs b/backend/crates/providers/xai/src/credential/refresh.rs index fdda1eb8e..5547355c3 100644 --- a/backend/crates/providers/xai/src/credential/refresh.rs +++ b/backend/crates/providers/xai/src/credential/refresh.rs @@ -645,7 +645,6 @@ impl GrokCredentialRefreshService { access_token, SecretValue::new(subject), email.map(SecretValue::new), - crate::transport::GROK_CLIENT_VERSION, ) .await { diff --git a/backend/crates/providers/xai/src/lib.rs b/backend/crates/providers/xai/src/lib.rs index 38c8a39a7..b22ebb581 100644 --- a/backend/crates/providers/xai/src/lib.rs +++ b/backend/crates/providers/xai/src/lib.rs @@ -18,8 +18,10 @@ use gateway_core::routing::ProviderKind; use gateway_core::task::WorkerContribution; use crate::admin::{XaiAdminProvider, XaiAdminServices}; +use crate::transport::profile::{GrokCliReleaseService, OfficialGrokCliReleaseTransport}; -pub use config::{XaiConfig, XaiConfigError}; +pub use config::{XaiConfig, XaiConfigError, XaiWireProfileConfig}; +pub use transport::XaiWireProfileState; pub use credential::{ AllowedRedirectUri, AuthorizationCallback, AuthorizationCodeGrant, CallbackRejection, @@ -48,6 +50,7 @@ pub use credential::{ VerifiedTokenSet, parse_oauth_error, parse_refresh_success, }; pub use provider::GrokBuildProvider; +pub use transport::profile::{GROK_CLI_RELEASE_URL, GrokCliReleaseError}; pub use transport::{ GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, @@ -88,7 +91,14 @@ pub async fn initialize( 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)?; + let cli_release = Arc::new(GrokCliReleaseService::new( + profile.clone(), + Arc::new( + OfficialGrokCliReleaseTransport::new().map_err(|_| XaiInitializeError::Transport)?, + ), + )); let provider_kind = ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| XaiInitializeError::ProviderKind)?; let accounts: Arc = ports.accounts(); @@ -112,10 +122,12 @@ pub async fn initialize( repository.clone(), catalog_transport, catalog_cache_port, + profile.clone(), )); let quota = Arc::new(GrokCredentialQuotaService::new( repository.clone(), billing_transport, + profile.clone(), )); let selector: Arc = Arc::new(GrokAccountSessionSelector::new( repository.clone(), @@ -137,6 +149,7 @@ pub async fn initialize( ); let oauth = Arc::new(GrokOAuthClient::new( oauth_config.clone(), + profile.clone(), oauth_transport, token_verifier, )); @@ -155,9 +168,11 @@ pub async fn initialize( inference, Arc::clone(&catalog), credential_recovery, + profile.clone(), )); let admin_provider: Arc = Arc::new(XaiAdminProvider::new( provider_kind.clone(), + profile, Arc::clone(&accounts), XaiAdminServices { repository, @@ -170,9 +185,16 @@ pub async fn initialize( runtime_policy, }, )); - let worker_contributions = - provider::worker_contributions(refresh, quota, catalog, accounts, instances, provider_kind) - .map_err(|_| XaiInitializeError::Worker)?; + let worker_contributions = provider::worker_contributions( + refresh, + quota, + catalog, + accounts, + instances, + provider_kind, + cli_release, + ) + .map_err(|_| XaiInitializeError::Worker)?; Ok(ProviderBundle { core_provider, diff --git a/backend/crates/providers/xai/src/provider.rs b/backend/crates/providers/xai/src/provider.rs index ee77599d2..92521df2b 100644 --- a/backend/crates/providers/xai/src/provider.rs +++ b/backend/crates/providers/xai/src/provider.rs @@ -35,6 +35,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::GrokCatalogCapabilityEvidence; +use crate::XaiWireProfileState; use crate::credential::{ GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRecovery, GrokCredentialRecoveryOutcome, GrokCredentialRefreshOutcome, GrokCredentialRefreshService, @@ -43,6 +44,7 @@ use crate::credential::{ use crate::transport::canonical::GrokCanonicalDecoder; use crate::transport::config::XAI_PROVIDER_NAME; use crate::transport::headers::{GrokClientIdentity, build_grok_headers}; +use crate::transport::profile::{GROK_CLI_RELEASE_POLL_INTERVAL, GrokCliReleaseService}; use crate::transport::{ GrokCredentialFailure, GrokInferenceRequest, GrokInferenceTransport, GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokProviderConfigError, @@ -56,6 +58,7 @@ const XAI_SESSION_OUTPUT_LIMIT: usize = 4_096; const MIN_REASONING_CIPHERTEXT_BYTES: usize = 50; const MIN_REASONING_CIPHERTEXT_ENTROPY: f64 = 0.85; const REASONING_DECODE_FAILED_CODE: &str = "reasoning_decode_failed"; +const RESPONSE_NOT_FOUND_CODE: &str = "not_found"; /// Official Grok Build provider with injected session selection and HTTP SSE /// transport ports. @@ -69,6 +72,7 @@ pub struct GrokBuildProvider { catalog: Arc, credential_recovery: Arc, client_identity: GrokClientIdentity, + wire_profile: XaiWireProfileState, } impl GrokBuildProvider { @@ -79,6 +83,7 @@ impl GrokBuildProvider { transport: Arc, catalog: Arc, credential_recovery: Arc, + wire_profile: XaiWireProfileState, ) -> Self { Self { selector, @@ -86,6 +91,7 @@ impl GrokBuildProvider { catalog, credential_recovery, client_identity: GrokClientIdentity::new(), + wire_profile, } } @@ -277,6 +283,7 @@ impl Provider for GrokBuildProvider { Arc::clone(&self.transport), GrokStreamAttempt { client_identity: self.client_identity.clone(), + wire_profile: self.wire_profile.clone(), credential_recovery: Arc::clone(&self.credential_recovery), instance, request: upstream_request, @@ -682,6 +689,7 @@ fn protocol_sent() -> ProviderError { struct GrokStreamAttempt { client_identity: GrokClientIdentity, + wire_profile: XaiWireProfileState, credential_recovery: Arc, instance: GrokProviderInstanceConfig, request: GrokResponsesRequest, @@ -698,6 +706,7 @@ fn cold_http_sse_stream( ) -> EventStream { let GrokStreamAttempt { client_identity, + wire_profile, credential_recovery, instance, request, @@ -714,7 +723,7 @@ fn cold_http_sse_stream( ))?; } let headers = build_grok_headers( - &instance, + &wire_profile, &session, &client_identity, context.request_id(), @@ -945,7 +954,13 @@ fn map_continuation_failure(context: &AttemptContext, error: ProviderError) -> P && error .upstream_code() .is_some_and(|code| code.as_str() == REASONING_DECODE_FAILED_CODE); - if is_reasoning_decode_failure { + let is_missing_native_response = context.continuation_attempt() == ContinuationAttempt::Native + && error.kind() == ProviderErrorKind::InvalidRequest + && error.upstream_status() == Some(404) + && error + .upstream_code() + .is_some_and(|code| code.as_str() == RESPONSE_NOT_FOUND_CODE); + if is_reasoning_decode_failure || is_missing_native_response { error .with_continuation_failure(ContinuationFailure::HistoryUnavailable) .with_replay_safe() @@ -1114,6 +1129,7 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60); const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60); const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30); const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60); +const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release"; pub(crate) fn worker_contributions( refresh: Arc, @@ -1122,9 +1138,11 @@ pub(crate) fn worker_contributions( accounts: Arc, instances: Arc, provider_kind: ProviderKind, + cli_release: Arc, ) -> Result, WorkerDefinitionError> { let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, XAI_PROVIDER_NAME)?; let catalog_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, XAI_PROVIDER_NAME)?; + let release_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, CLI_RELEASE_WORKER_OWNER)?; Ok(vec![ WorkerContribution::Registration(scheduled_registration( refresh_id, @@ -1142,6 +1160,13 @@ pub(crate) fn worker_contributions( provider_kind, }), )?), + WorkerContribution::Registration(scheduled_registration( + release_id, + GROK_CLI_RELEASE_POLL_INTERVAL, + Box::new(XaiCliReleaseTask { + service: cli_release, + }), + )?), ]) } @@ -1172,6 +1197,27 @@ struct XaiOAuthRefreshTask { service: Arc, } +struct XaiCliReleaseTask { + service: Arc, +} + +impl ScheduledTask for XaiCliReleaseTask { + fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { + Box::pin(async move { + let refresh = self.service.refresh(); + tokio::pin!(refresh); + let result = tokio::select! { + () = context.cancellation().cancelled() => return Ok(()), + result = &mut refresh => result, + }; + if let Err(error) = result { + tracing::warn!(error = %error, "xAI CLI release check failed"); + } + Ok(()) + }) + } +} + impl ScheduledTask for XaiOAuthRefreshTask { fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> { Box::pin(async move { diff --git a/backend/crates/providers/xai/src/transport/catalog.rs b/backend/crates/providers/xai/src/transport/catalog.rs index c2cb94a64..3908289ea 100644 --- a/backend/crates/providers/xai/src/transport/catalog.rs +++ b/backend/crates/providers/xai/src/transport/catalog.rs @@ -13,7 +13,7 @@ use serde_json::{Map, Value}; use url::Url; use zeroize::Zeroizing; -use crate::{GrokHeader, SecretValue}; +use crate::{GrokHeader, SecretValue, XaiWireProfileState}; /// 官方 Grok CLI proxy 模型目录 URL。 pub const GROK_MODEL_CATALOG_URL: &str = "https://cli-chat-proxy.grok.com/v1/models"; @@ -28,7 +28,6 @@ pub(crate) const MAX_CATALOG_MODELS: usize = 2_048; const MAX_DISPLAY_NAME_BYTES: usize = 256; const MAX_DESCRIPTION_BYTES: usize = 4 * 1024; const MAX_ETAG_BYTES: usize = 256; -const CLIENT_MODE: &str = "headless"; /// 构造模型目录 OAuth session 失败。 #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -44,7 +43,7 @@ pub struct GrokModelCatalogSession { access_token: SecretValue, user_id: SecretValue, email: Option, - client_version: String, + wire_profile: XaiWireProfileState, } impl GrokModelCatalogSession { @@ -57,15 +56,13 @@ impl GrokModelCatalogSession { access_token: SecretValue, user_id: SecretValue, email: Option, - client_version: impl Into, + wire_profile: XaiWireProfileState, ) -> Result { - let client_version = client_version.into(); if !valid_secret_header(&access_token, 64 * 1024) || !valid_secret_header(&user_id, 1_024) || email .as_ref() .is_some_and(|value| !valid_secret_header(value, 1_024)) - || !valid_header_atom(&client_version, 64) { return Err(GrokModelCatalogSessionError::InvalidHeaderData); } @@ -73,7 +70,7 @@ impl GrokModelCatalogSession { access_token, user_id, email, - client_version, + wire_profile, }) } } @@ -85,7 +82,7 @@ impl fmt::Debug for GrokModelCatalogSession { .field("access_token", &"[REDACTED]") .field("user_id", &"[REDACTED]") .field("email", &self.email.as_ref().map(|_| "[REDACTED]")) - .field("client_version", &self.client_version) + .field("wire_profile", &self.wire_profile) .finish() } } @@ -108,8 +105,11 @@ impl GrokModelCatalogRequest { ), GrokHeader::public("X-XAI-Token-Auth", "xai-grok-cli"), GrokHeader::sensitive("x-userid", session.user_id.clone()), - GrokHeader::public("x-grok-client-version", session.client_version.clone()), - GrokHeader::public("x-grok-client-mode", CLIENT_MODE), + GrokHeader::public( + "x-grok-client-version", + session.wire_profile.client_version(), + ), + GrokHeader::public("x-grok-client-mode", session.wire_profile.client_mode()), GrokHeader::public("accept", "application/json"), ]; if let Some(email) = &session.email { @@ -257,8 +257,11 @@ impl GrokBillingRequest { ), GrokHeader::public("X-XAI-Token-Auth", "xai-grok-cli"), GrokHeader::sensitive("x-userid", session.user_id.clone()), - GrokHeader::public("x-grok-client-version", session.client_version.clone()), - GrokHeader::public("x-grok-client-mode", CLIENT_MODE), + GrokHeader::public( + "x-grok-client-version", + session.wire_profile.client_version(), + ), + GrokHeader::public("x-grok-client-mode", session.wire_profile.client_mode()), GrokHeader::public("accept", "application/json"), ]; if let Some(email) = &session.email { @@ -1007,11 +1010,3 @@ fn valid_secret_header(value: &SecretValue, max_bytes: usize) -> bool { .bytes() .all(|byte| (0x20..=0x7e).contains(&byte)) } - -fn valid_header_atom(value: &str, max_bytes: usize) -> bool { - !value.is_empty() - && value.len() <= max_bytes - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) -} diff --git a/backend/crates/providers/xai/src/transport/config.rs b/backend/crates/providers/xai/src/transport/config.rs index 5f376619a..a6be815d0 100644 --- a/backend/crates/providers/xai/src/transport/config.rs +++ b/backend/crates/providers/xai/src/transport/config.rs @@ -5,11 +5,6 @@ use url::Url; pub const XAI_PROVIDER_NAME: &str = "xai"; pub const GROK_CLI_BASE_URL: &str = "https://cli-chat-proxy.grok.com/v1"; -pub const GROK_CLIENT_VERSION: &str = "0.2.106"; -pub const GROK_CLIENT_IDENTIFIER: &str = "grok-shell"; -pub const GROK_CLIENT_MODE: &str = "headless"; -pub const GROK_TARGET_OS: &str = "linux"; -pub const GROK_TARGET_ARCH: &str = "x86_64"; const OFFICIAL_GROK_HOST: &str = "cli-chat-proxy.grok.com"; const OFFICIAL_GROK_BASE_PATH: &str = "/v1"; @@ -27,7 +22,7 @@ pub struct GrokProviderInstanceConfig { } impl GrokProviderInstanceConfig { - /// Provider instance 只持久化公共 endpoint;transport 与版本由 adapter 代码固定。 + /// Provider instance 只持久化公共 endpoint;transport 由 adapter 固定。 pub fn from_snapshot(instance: &ProviderInstance) -> Result { if instance.provider().as_str() != XAI_PROVIDER_NAME { return Err(GrokProviderConfigError::ProviderMismatch); @@ -61,11 +56,6 @@ impl GrokProviderInstanceConfig { pub const fn transport(&self) -> GrokProviderTransport { GrokProviderTransport::HttpSse } - - #[must_use] - pub const fn client_version(&self) -> &'static str { - GROK_CLIENT_VERSION - } } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/backend/crates/providers/xai/src/transport/headers.rs b/backend/crates/providers/xai/src/transport/headers.rs index 9e97987be..de15e77bb 100644 --- a/backend/crates/providers/xai/src/transport/headers.rs +++ b/backend/crates/providers/xai/src/transport/headers.rs @@ -4,11 +4,9 @@ use gateway_core::engine::ModelRequestId; use gateway_core::routing::UpstreamModelId; use uuid::Uuid; -use crate::SecretValue; +use crate::{SecretValue, XaiWireProfileState}; -use super::{ - GROK_CLIENT_IDENTIFIER, GROK_CLIENT_MODE, GrokProviderInstanceConfig, SelectedGrokSession, -}; +use super::SelectedGrokSession; /// 进程级 Grok Build 客户端身份;请求级身份在构造 headers 时单独生成。 #[derive(Clone)] @@ -108,7 +106,7 @@ impl GrokHeader { } pub fn build_grok_headers( - instance: &GrokProviderInstanceConfig, + profile: &XaiWireProfileState, session: &SelectedGrokSession, client_identity: &GrokClientIdentity, request_id: &ModelRequestId, @@ -129,16 +127,10 @@ pub fn build_grok_headers( GrokHeader::public("X-XAI-Token-Auth", "xai-grok-cli"), GrokHeader::public("x-authenticateresponse", "authenticate-response"), GrokHeader::sensitive("x-grok-user-id", session.user_id().clone()), - GrokHeader::public("x-grok-client-version", instance.client_version()), - GrokHeader::public("x-grok-client-mode", GROK_CLIENT_MODE), - GrokHeader::public("x-grok-client-identifier", GROK_CLIENT_IDENTIFIER), - GrokHeader::public( - "user-agent", - format!( - "{GROK_CLIENT_IDENTIFIER}/{} (linux; x86_64)", - instance.client_version() - ), - ), + GrokHeader::public("x-grok-client-version", profile.client_version()), + GrokHeader::public("x-grok-client-mode", profile.client_mode()), + GrokHeader::public("x-grok-client-identifier", profile.client_identifier()), + GrokHeader::public("user-agent", profile.user_agent()), GrokHeader::public("content-type", "application/json"), GrokHeader::public("accept", "text/event-stream"), GrokHeader::public("accept-encoding", "identity"), @@ -156,11 +148,11 @@ pub fn build_grok_headers( "x-grok-conv-id", SecretValue::new(upstream_session_id.to_owned()), )); - headers.push(GrokHeader::sensitive( - "x-grok-session-id", - SecretValue::new(upstream_session_id.to_owned()), - )); if let Some(turn_index) = turn_index { + headers.push(GrokHeader::sensitive( + "x-grok-session-id", + SecretValue::new(upstream_session_id.to_owned()), + )); headers.push(GrokHeader::public("x-grok-turn-idx", turn_index)); } } diff --git a/backend/crates/providers/xai/src/transport/mod.rs b/backend/crates/providers/xai/src/transport/mod.rs index 14fd834f4..f353b7d10 100644 --- a/backend/crates/providers/xai/src/transport/mod.rs +++ b/backend/crates/providers/xai/src/transport/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod catalog; pub(crate) mod config; pub(crate) mod headers; pub(crate) mod network; +pub(crate) mod profile; mod request; mod session; #[expect( @@ -27,9 +28,8 @@ pub use catalog::{ MAX_GROK_MODEL_CATALOG_BYTES, parse_grok_billing, parse_grok_model_catalog, }; pub use config::{ - GROK_CLI_BASE_URL, GROK_CLIENT_IDENTIFIER, GROK_CLIENT_MODE, GROK_CLIENT_VERSION, - GROK_TARGET_ARCH, GROK_TARGET_OS, GrokProviderConfigError, GrokProviderInstanceConfig, - GrokProviderTransport, XAI_PROVIDER_NAME, + GROK_CLI_BASE_URL, GrokProviderConfigError, GrokProviderInstanceConfig, GrokProviderTransport, + XAI_PROVIDER_NAME, }; pub use headers::{GrokClientIdentity, GrokHeader, GrokHeaderValue, build_grok_headers}; pub use network::{ @@ -37,6 +37,7 @@ pub use network::{ GrokReqwestTransportBuildError, OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport, }; +pub use profile::XaiWireProfileState; pub use request::{GrokRequestEncodeError, GrokResponsesRequest}; pub use session::{ GrokCredentialFailure, GrokCredentialFeedbackFuture, GrokSessionAffinityKey, diff --git a/backend/crates/providers/xai/src/transport/profile.rs b/backend/crates/providers/xai/src/transport/profile.rs new file mode 100644 index 000000000..b81062893 --- /dev/null +++ b/backend/crates/providers/xai/src/transport/profile.rs @@ -0,0 +1,193 @@ +//! Grok CLI runtime wire profile. + +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; + +use chrono::{DateTime, Utc}; +use futures::{StreamExt as _, future::BoxFuture}; +use reqwest::{Client, redirect::Policy}; +use serde::Deserialize; +use url::Url; + +use crate::config::XaiWireProfileConfig; + +#[derive(Debug, Clone)] +pub struct XaiWireProfileState(Arc>); + +impl XaiWireProfileState { + #[must_use] + pub(crate) fn new(profile: XaiWireProfileConfig) -> Self { + Self(Arc::new(RwLock::new(profile))) + } + + #[must_use] + pub fn client_identifier(&self) -> String { + self.snapshot().client_identifier + } + #[must_use] + pub fn client_version(&self) -> String { + self.snapshot().client_version + } + #[must_use] + pub fn client_mode(&self) -> String { + self.snapshot().client_mode + } + #[must_use] + pub fn target_os(&self) -> String { + self.snapshot().target_os + } + #[must_use] + pub fn target_arch(&self) -> String { + self.snapshot().target_arch + } + #[must_use] + pub fn verified_at(&self) -> DateTime { + self.snapshot().verified_at + } + + #[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 + ) + } + + #[must_use] + pub fn snapshot(&self) -> XaiWireProfileConfig { + self.0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub(crate) fn update_client_version(&self, version: &str) { + let mut profile = self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if profile.client_version != version { + profile.client_version = version.to_owned(); + } + } +} + +pub const GROK_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@xai-official%2Fgrok/latest"; +pub const GROK_CLI_RELEASE_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +const RELEASE_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RELEASE_BYTES: usize = 64 * 1024; + +pub trait GrokCliReleaseTransport: Send + Sync { + fn fetch(&self) -> BoxFuture<'_, Result>; +} + +#[derive(Clone)] +pub struct OfficialGrokCliReleaseTransport { + client: Client, + endpoint: Url, +} + +impl OfficialGrokCliReleaseTransport { + pub fn new() -> Result { + let endpoint = + Url::parse(GROK_CLI_RELEASE_URL).map_err(|_| GrokCliReleaseError::InvalidEndpoint)?; + let client = Client::builder() + .https_only(true) + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(RELEASE_TIMEOUT) + .build() + .map_err(|_| GrokCliReleaseError::ClientInitialization)?; + Ok(Self { client, endpoint }) + } +} + +impl GrokCliReleaseTransport for OfficialGrokCliReleaseTransport { + fn fetch(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response = self.client.get(self.endpoint.clone()).send().await?; + if !response.status().is_success() { + return Err(GrokCliReleaseError::HttpStatus(response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_RELEASE_BYTES as u64) + { + return Err(GrokCliReleaseError::ResponseTooLarge); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if body + .len() + .checked_add(chunk.len()) + .is_none_or(|size| size > MAX_RELEASE_BYTES) + { + return Err(GrokCliReleaseError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + parse_release(&body) + }) + } +} + +#[derive(Clone)] +pub struct GrokCliReleaseService { + profile: XaiWireProfileState, + transport: Arc, +} + +impl GrokCliReleaseService { + #[must_use] + pub fn new(profile: XaiWireProfileState, transport: Arc) -> Self { + Self { profile, transport } + } + + pub async fn refresh(&self) -> Result { + let version = self.transport.fetch().await?; + self.profile.update_client_version(&version); + Ok(version) + } +} + +#[derive(Deserialize)] +struct NpmRelease { + version: String, +} + +fn parse_release(body: &[u8]) -> Result { + let release: NpmRelease = + serde_json::from_slice(body).map_err(|_| GrokCliReleaseError::InvalidDocument)?; + if semver::Version::parse(&release.version).is_err() { + return Err(GrokCliReleaseError::InvalidVersion); + } + Ok(release.version) +} + +#[derive(Debug, thiserror::Error)] +pub enum GrokCliReleaseError { + #[error("Grok CLI release client initialization failed")] + ClientInitialization, + #[error("Grok CLI release endpoint is invalid")] + InvalidEndpoint, + #[error("Grok CLI release request failed")] + Request(#[from] reqwest::Error), + #[error("Grok CLI release endpoint returned HTTP {0}")] + HttpStatus(u16), + #[error("Grok CLI release response exceeded the size limit")] + ResponseTooLarge, + #[error("Grok CLI release document is invalid")] + InvalidDocument, + #[error("Grok CLI release version is invalid")] + InvalidVersion, +} diff --git a/backend/crates/providers/xai/src/transport/request.rs b/backend/crates/providers/xai/src/transport/request.rs index 1f67c9e09..b4c68cdbd 100644 --- a/backend/crates/providers/xai/src/transport/request.rs +++ b/backend/crates/providers/xai/src/transport/request.rs @@ -60,6 +60,7 @@ const SESSION_FIELDS: &[&str] = &[ ]; const FOREIGN_CLIENT_METADATA_FIELDS: &[&str] = &["x-openai-subagent"]; const MAX_SESSION_SEED_BYTES: usize = 1_024; +const GROK_CACHE_ROUTE_TOOLS: &[&str] = &["web_search", "x_search"]; /// 保留客户端 OpenAI Responses object 的 xAI 上游请求。 pub struct GrokResponsesRequest { @@ -187,6 +188,7 @@ impl GrokResponsesRequest { .ok_or(GrokRequestEncodeError::InvalidProtocolPayload)?; let mut body = payload.body().clone(); let session_seed = explicit_session_seed(request, &body); + let enable_cache_route = session_seed.is_some() && !has_client_tool_contract(&body); let identity = resolve_session_identity( client_api_key_ref.as_str(), upstream_model, @@ -196,6 +198,9 @@ impl GrokResponsesRequest { sanitize_account_identity(&mut body); sanitize_foreign_client_metadata(&mut body); let response_transform = normalize_responses_request(&mut body)?; + if enable_cache_route { + enable_grok_prompt_cache_route(&mut body); + } let (session_id, affinity) = identity.map_or((None, None), |(session_id, affinity)| { (Some(session_id), Some(affinity)) }); @@ -333,6 +338,30 @@ fn sanitize_foreign_client_metadata(body: &mut Map) { } } +fn has_client_tool_contract(body: &Map) -> bool { + if body.contains_key("tools") || body.contains_key("tool_choice") { + return true; + } + body.get("input") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + }) +} + +fn enable_grok_prompt_cache_route(body: &mut Map) { + // xAI enables the reusable prompt-cache route only when native search tools + // are declared. `tool_choice: none` keeps that routing hint behavior-neutral. + let tools = GROK_CACHE_ROUTE_TOOLS + .iter() + .map(|tool| json_object([("type", Value::String((*tool).to_owned()))])) + .collect(); + body.insert("tools".to_owned(), Value::Array(tools)); + body.insert("tool_choice".to_owned(), Value::String("none".to_owned())); +} + fn explicit_session_seed(request: &GenerateRequest, body: &Map) -> Option { request .prompt_cache_key() diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 15a28e3b9..b113e9086 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -1,11 +1,11 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; -use futures::future::BoxFuture; +use futures::{StreamExt as _, future::BoxFuture}; use gateway_admin::model::accounts::{ AccountAvailability as AdminAccountAvailability, AccountRecord, }; @@ -18,10 +18,16 @@ use gateway_admin::model::provider_credentials::{ use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::ProviderAdminErrorKind; use gateway_core::engine::credential::{ - AccountAvailability, CredentialRevision, OpaqueProviderData, ProviderAccount, - ProviderAccountId, ProviderAccountStore, + AccountAvailability, AccountRuntimeSignals, AccountSelectionPolicy, CredentialRevision, + NewProviderAccount, OpaqueProviderData, PlaintextCredential, ProviderAccount, + ProviderAccountId, ProviderAccountStore, RotationStrategy, }; -use gateway_core::operation::Operation; +use gateway_core::engine::provider::ProviderRequest; +use gateway_core::engine::{ + AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext, +}; +use gateway_core::event::GatewayEvent; +use gateway_core::operation::{Operation, OperationKind}; use gateway_core::policy::ClientApiKeyId; use gateway_core::provider_ports::{ NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingFlowPort, OAuthPendingPutOutcome, @@ -31,26 +37,182 @@ use gateway_core::provider_ports::{ ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStorePorts, }; -use gateway_core::routing::{ProviderKind, UpstreamModelId}; +use gateway_core::routing::{ + ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderKind, + ProviderModel, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId, +}; use gateway_core::task::WorkerKind; use provider_xai::{ - DiscoveryDocument, GrokOAuthConfig, PendingAuthorization, RedirectUriAllowlist, XaiConfig, + DiscoveryDocument, GrokOAuthConfig, PendingAuthorization, RedirectUriAllowlist, }; use serde_json::{Map, Value, json}; use sha2::{Digest as _, Sha256}; -use crate::support::{MemoryProviderAccountStore, create_input, instance_id, seed_input}; +use crate::support::{ + MemoryProviderAccountStore, create_input, instance_id, seed_input, xai_config, +}; #[tokio::test] -async fn xai_bundle_exposes_core_admin_and_drains_two_worker_contributions_once() { - let mut bundle = provider_xai::initialize(XaiConfig::default(), provider_ports()) +#[ignore = "requires XAI_REAL_ACCOUNT_FIXTURE and consumes live xAI quota; refresh token may rotate"] +async fn real_xai_conversation_crosses_production_provider_boundaries() { + assert_eq!( + std::env::var("XAI_ALLOW_DESTRUCTIVE_FIXTURE_REFRESH").as_deref(), + Ok("1"), + "set XAI_ALLOW_DESTRUCTIVE_FIXTURE_REFRESH=1 for the disposable real fixture", + ); + let fixture = std::env::var("XAI_REAL_ACCOUNT_FIXTURE") + .expect("XAI_REAL_ACCOUNT_FIXTURE must point to a Grok JSON document"); + let payload = serde_json::from_slice::(&std::fs::read(fixture).expect("read fixture")) + .expect("parse fixture"); + let Value::Object(document) = payload else { + panic!("real xAI fixture must be an object"); + }; + let store = Arc::new(MemoryProviderAccountStore::default()); + let bundle = provider_xai::initialize( + xai_config(), + provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + ) + .await + .expect("xAI bundle"); + let prepared = bundle + .admin_provider() + .prepare_import(PrepareCredentialImport { + provider_instance_id: instance_id(), + document: ProviderDocument::new(OpaqueProviderData::new(document)), + }) + .await + .expect("prepare real xAI import"); + for credential in prepared.credentials { + store + .create_account(real_prepared_account(credential)) + .await + .expect("seed verified xAI account"); + } + + let instance = ProviderInstance::new( + instance_id(), + ProviderKind::new("xai").expect("provider"), + provider_xai::GROK_CLI_BASE_URL.to_owned(), + true, + InstanceHealth::Healthy, + ); + let models = bundle + .core_provider() + .query_model_capabilities(&instance) + .await + .expect("query live xAI catalog"); + let model = models.first().expect("one live generation model"); + let operation = bundle + .admin_provider() + .connection_test_operation(model.upstream_model(), "Reply with exactly CPR_REAL_OK.") + .expect("real connection operation"); + let request = real_planned_request(instance, model.upstream_model().clone(), operation); + let mut stream = bundle + .core_provider() + .execute(request, real_xai_attempt_context()) + .await + .expect("prepare real xAI stream"); + let mut started = false; + let mut completed = false; + let mut usage = false; + let mut text = false; + while let Some(event) = stream.next().await { + for fact in event.expect("real xAI event").canonical_facts() { + match fact { + GatewayEvent::Started(_) => started = true, + GatewayEvent::TextDelta(_) => text = true, + GatewayEvent::Usage(_) => usage = true, + GatewayEvent::Completed(_) => completed = true, + _ => {} + } + } + } + assert!(started && text && usage && completed); +} + +fn real_prepared_account( + value: gateway_admin::model::provider_credentials::PreparedCredentialCreate, +) -> NewProviderAccount { + let account = ProviderAccount::new( + value.account_id, + value.provider_instance_id, + value.provider_kind, + value.name, + value.upstream_user_id, + CredentialRevision::new(1).expect("initial revision"), + value.access_token_expires_at.into(), + ) + .with_profile(value.email, value.upstream_account_id, value.plan_type) + .with_runtime_state(value.enabled, AccountAvailability::Ready, None) + .with_refresh_schedule( + value.has_refresh_token, + value.next_refresh_at.map(Into::into), + ); + NewProviderAccount { + account, + credential: PlaintextCredential::new( + value.provider_material.into_provider_data().into_inner(), + ), + } +} + +fn real_planned_request( + instance: ProviderInstance, + upstream_model: UpstreamModelId, + operation: Operation, +) -> ProviderRequest { + let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model"); + let snapshot = RuntimeSnapshot::new( + ConfigRevision::new(1).expect("revision"), + real_selection_policy(), + vec![instance.clone()], + vec![ProviderModel::new( + instance.id().clone(), + upstream_model, + ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), 1_000_000, None), + )], + vec![], + ) + .expect("snapshot"); + let plan = snapshot + .plan(&public_model, &operation, &RoutingContext::default()) + .expect("route plan"); + ProviderRequest::new(operation, plan.candidates()[0].clone()) +} + +fn real_selection_policy() -> AccountSelectionPolicy { + AccountSelectionPolicy::new( + RotationStrategy::Smart, + NonZeroU32::new(1).expect("concurrency"), + Duration::ZERO, + ) +} + +fn real_xai_attempt_context() -> AttemptContext { + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_real_xai_conversation").expect("request"), + ClientApiKeyId::new("real_test_client").expect("client"), + ), + NonZeroU32::new(1).expect("attempt"), + SystemTime::now() + Duration::from_secs(90), + real_selection_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None), + None, + CancellationToken::new(), + ) +} + +#[tokio::test] +async fn xai_bundle_exposes_core_admin_and_drains_worker_contributions_once() { + let mut bundle = provider_xai::initialize(xai_config(), provider_ports()) .await .expect("xAI bundle"); assert_eq!(bundle.core_provider().name(), "xai"); assert_eq!(bundle.admin_provider().provider_kind().as_str(), "xai"); let contributions = bundle.take_worker_contributions(); - assert_eq!(contributions.len(), 2); + assert_eq!(contributions.len(), 3); assert!( contributions .iter() @@ -66,7 +228,7 @@ async fn xai_bundle_exposes_core_admin_and_drains_two_worker_contributions_once( #[tokio::test] async fn xai_admin_provider_validates_known_billing_breakdown() { - let bundle = provider_xai::initialize(XaiConfig::default(), provider_ports()) + let bundle = provider_xai::initialize(xai_config(), provider_ports()) .await .expect("xAI bundle"); let admin = bundle.admin_provider(); @@ -101,7 +263,7 @@ async fn xai_admin_provider_validates_known_billing_breakdown() { async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() { let pending = Arc::new(TestOAuthPending::default()); let bundle = provider_xai::initialize( - XaiConfig::default(), + xai_config(), provider_ports_with( Arc::new(MemoryProviderAccountStore::default()), Arc::clone(&pending), @@ -207,7 +369,7 @@ async fn xai_admin_provider_projects_cached_quota_models_and_canonical_export() let account = store.account(&input.account_id).expect("stored account"); let record = account_record(&account); let bundle = provider_xai::initialize( - XaiConfig::default(), + xai_config(), provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), ) .await @@ -282,7 +444,7 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() { let account = store.account(&input.account_id).expect("stored account"); let record = account_record(&account); let bundle = provider_xai::initialize( - XaiConfig::default(), + xai_config(), provider_ports_with(store, Arc::new(TestOAuthPending::default())), ) .await @@ -316,8 +478,7 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() { } fn pending_payload(flow_id: &str, owner_ref: &str) -> OpaqueProviderData { - let config = GrokOAuthConfig::official(provider_xai::transport::GROK_CLIENT_VERSION) - .expect("official config"); + let config = GrokOAuthConfig::official().expect("official config"); let discovery = DiscoveryDocument::parse( &config, include_bytes!("credential/fixtures/discovery.json"), @@ -338,7 +499,7 @@ fn pending_payload(flow_id: &str, owner_ref: &str) -> OpaqueProviderData { "schema_version": 1, "flow_id": flow_id, "owner_ref": owner_ref, - "expires_at": (Utc::now() + chrono::Duration::minutes(10)).to_rfc3339(), + "expires_at": (Utc::now() + chrono::Duration::minutes(30)).to_rfc3339(), "server_state": server_state, "mutation": { "expected_config_revision": 7, @@ -434,16 +595,29 @@ impl ProviderLeasePort for TestLeases { fn load_state<'a>( &'a self, _: &'a gateway_core::routing::ProviderInstanceId, - _: &'a [ProviderAccountId], + accounts: &'a [ProviderAccountId], ) -> BoxFuture< 'a, Result, > { - Box::pin(async { + Box::pin(async move { + let signals = accounts + .iter() + .cloned() + .map(|account| { + ( + account, + AccountRuntimeSignals { + in_flight: 0, + last_started_at: None, + quota_reset_at: None, + quota_remaining_rank: None, + }, + ) + }) + .collect(); Ok(gateway_core::provider_ports::ProviderSchedulingState::new( - Default::default(), - None, - 0, + signals, None, 0, )) }) } @@ -589,7 +763,11 @@ impl TestOAuthPending { fn insert(&self, flow_id: &str, owner: String, payload: OpaqueProviderData) { self.values.lock().expect("OAuth pending").insert( ("xai".to_owned(), flow_id.to_owned()), - (owner, payload, SystemTime::now() + Duration::from_secs(600)), + ( + owner, + payload, + SystemTime::now() + Duration::from_secs(1_800), + ), ); } diff --git a/backend/crates/providers/xai/tests/config.rs b/backend/crates/providers/xai/tests/config.rs index 33b048cea..cfbda8d95 100644 --- a/backend/crates/providers/xai/tests/config.rs +++ b/backend/crates/providers/xai/tests/config.rs @@ -2,12 +2,21 @@ use std::path::Path; use provider_xai::XaiConfig; +use crate::support::xai_config; + #[test] -fn xai_config_accepts_only_the_fixed_official_protocol_shape() { - let mut config: XaiConfig = serde_json::from_str("{}").expect("empty xAI config"); +fn xai_config_requires_and_freezes_the_explicit_wire_profile() { + let mut config = xai_config(); config .resolve_and_validate(Path::new("/srv/gateway")) - .expect("fixed official protocol"); + .expect("valid xAI wire profile"); - assert!(serde_json::from_str::(r#"{"client_version":"override"}"#).is_err()); + let profile = config.wire_profile_state(); + assert_eq!(profile.client_version(), "0.2.106"); + assert_eq!(profile.user_agent(), "grok-shell/0.2.106 (linux; x86_64)"); + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::( + r#"{"wire_profile":{"client_identifier":"grok-shell","client_version":"0.2.106","client_mode":"headless","target_os":"linux","target_arch":"x86_64","verified_at":"2026-07-21T00:00:00+08:00","unknown":true}}"#, + ) + .is_err()); } diff --git a/backend/crates/providers/xai/tests/credential/authorization_code.rs b/backend/crates/providers/xai/tests/credential/authorization_code.rs index c8000a2e5..ad3d414f6 100644 --- a/backend/crates/providers/xai/tests/credential/authorization_code.rs +++ b/backend/crates/providers/xai/tests/credential/authorization_code.rs @@ -25,7 +25,7 @@ fn callback_debug_should_redact_code_and_state() { #[test] fn pending_authorization_should_round_trip_only_through_server_state() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config"); + let config = GrokOAuthConfig::official().expect("fixture config"); let discovery = DiscoveryDocument::parse(&config, include_bytes!("fixtures/discovery.json")) .expect("fixture discovery"); let redirect = "https://gateway.example/admin/xai/callback"; diff --git a/backend/crates/providers/xai/tests/credential/catalog.rs b/backend/crates/providers/xai/tests/credential/catalog.rs index 7c7e3d734..1881f65e4 100644 --- a/backend/crates/providers/xai/tests/credential/catalog.rs +++ b/backend/crates/providers/xai/tests/credential/catalog.rs @@ -14,10 +14,10 @@ use provider_xai::{ GROK_CLI_BASE_URL, GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse, GrokCredentialCatalogCache, GrokCredentialCatalogError, GrokCredentialCatalogSeed, - GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRepository, - GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportError, - GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture, - GrokModelCatalogTransportResponse, GrokQuotaError, SecretValue, + GrokCredentialRepository, GrokModelCatalogRequest, GrokModelCatalogTransport, + GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind, + GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokQuotaError, + SecretValue, }; use crate::support::{ @@ -188,7 +188,7 @@ async fn concurrent_cold_scheduling_hydration_reads_quota_once() { let account = store .account(&account_id("quota-hydration")) .expect("created account"); - let service = GrokCredentialQuotaService::new(repository, QueueBillingTransport::failure()); + let service = crate::support::grok_quota_service(repository, QueueBillingTransport::failure()); join_all((0..32).map(|_| service.prepare_scheduling(std::slice::from_ref(&account)))).await; @@ -207,7 +207,7 @@ async fn synchronization_caches_each_account_and_returns_strict_union() { OFFICIAL_FIXTURE.to_vec(), OFFICIAL_FIXTURE.to_vec(), ]); - let service = GrokCredentialCatalogService::new(repository, transport, cache_port); + let service = crate::support::grok_catalog_service(repository, transport, cache_port); assert_eq!(service.catalog_generation().get(), 0); let snapshot = service .synchronize_instance( @@ -246,13 +246,13 @@ async fn single_account_catalog_refresh_and_read_use_provider_cache_boundary() { let (_, repository) = repository_with_accounts(&[("account-models", "subject-models")]).await; let cache = MemoryGrokCatalogCache::shared(); let cache_port: Arc = cache; - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]), cache_port, ); let refreshed = service - .refresh_account_catalog(&account_id("account-models"), "0.2.101") + .refresh_account_catalog(&account_id("account-models")) .await .expect("refresh one account catalog"); assert_eq!(refreshed.seed().models(), ["grok-4.5"]); @@ -272,7 +272,7 @@ async fn single_account_catalog_refresh_and_read_use_provider_cache_boundary() { async fn single_account_catalog_read_miss_does_not_call_upstream() { let (_, repository) = repository_with_accounts(&[("account-models-miss", "subject-models")]).await; - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::failure(), MemoryGrokCatalogCache::shared(), @@ -297,7 +297,7 @@ async fn disabled_accounts_are_not_sent_to_catalog_transport() { .await .expect("disable"); let cache_port: Arc = MemoryGrokCatalogCache::shared(); - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::from_bodies([]), cache_port, @@ -326,7 +326,7 @@ async fn quota_exhausted_account_remains_eligible_for_catalog_discovery() { }) .await .expect("mark quota exhausted"); - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]), MemoryGrokCatalogCache::shared(), @@ -344,8 +344,11 @@ async fn quota_exhausted_account_remains_eligible_for_catalog_discovery() { async fn one_upstream_failure_rejects_the_whole_catalog_cycle() { let (_, repository) = repository_with_accounts(&[("failed", "subject-failed")]).await; let cache_port: Arc = MemoryGrokCatalogCache::shared(); - let service = - GrokCredentialCatalogService::new(repository, QueueCatalogTransport::failure(), cache_port); + let service = crate::support::grok_catalog_service( + repository, + QueueCatalogTransport::failure(), + cache_port, + ); assert!(matches!( service .synchronize_instance(&instance(), ConfigRevision::new(1).expect("revision")) @@ -361,7 +364,7 @@ async fn conflicting_facts_for_same_slug_fail_closed() { let mut conflicting: serde_json::Value = serde_json::from_slice(OFFICIAL_FIXTURE).expect("fixture JSON"); conflicting["data"][0]["name"] = serde_json::json!("Different name"); - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::from_bodies([ OFFICIAL_FIXTURE.to_vec(), @@ -392,7 +395,7 @@ fn seed_rejects_duplicates_and_supports_exact_membership() { #[tokio::test] async fn fetch_seed_rejects_non_header_safe_identity() { let (_, repository) = repository_with_accounts(&[]).await; - let service = GrokCredentialCatalogService::new( + let service = crate::support::grok_catalog_service( repository, QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]), MemoryGrokCatalogCache::shared(), @@ -403,7 +406,6 @@ async fn fetch_seed_rejects_non_header_safe_identity() { SecretValue::new("access"), SecretValue::new("非-ascii"), None, - "0.2.101", ) .await, Err(GrokCredentialCatalogError::InvalidCredentialData) @@ -416,7 +418,7 @@ async fn quota_refresh_persists_dynamic_provider_document_and_projects_known_fie let transport = QueueBillingTransport::success( br#"{"config":{"creditUsagePercent":37.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-13T00:00:00Z","end":"2026-07-20T00:00:00Z"},"prepaidBalance":{"val":2500},"futureWindow":{"kind":"rolling"}}}"#, ); - let service = GrokCredentialQuotaService::new(repository, transport.clone()); + let service = crate::support::grok_quota_service(repository, transport.clone()); let snapshot = service .refresh_account(&account_id("quota")) @@ -461,7 +463,7 @@ async fn recovered_quota_refresh_releases_existing_quota_and_cooldown_isolation( let (store, repository) = repository_with_accounts(&[(suffix, suffix)]).await; let id = account_id(suffix); set_account_state(&store, &id, availability, cooldown_until).await; - GrokCredentialQuotaService::new( + crate::support::grok_quota_service( repository, QueueBillingTransport::success(br#"{"config":{"creditUsagePercent":25}}"#), ) @@ -482,7 +484,7 @@ async fn authoritative_exhaustion_preserves_quota_exhausted_state() { let id = account_id("still-exhausted"); set_account_state(&store, &id, AccountAvailability::QuotaExhausted, None).await; - GrokCredentialQuotaService::new( + crate::support::grok_quota_service( repository, QueueBillingTransport::success(br#"{"config":{"creditUsagePercent":100}}"#), ) @@ -506,7 +508,7 @@ async fn recovered_quota_does_not_clear_terminal_account_states() { let (store, repository) = repository_with_accounts(&[(suffix, suffix)]).await; let id = account_id(suffix); set_account_state(&store, &id, availability, None).await; - GrokCredentialQuotaService::new( + crate::support::grok_quota_service( repository, QueueBillingTransport::success(br#"{"config":{"creditUsagePercent":10}}"#), ) @@ -541,7 +543,7 @@ async fn quota_refresh_does_not_overwrite_a_newer_cooldown() { body: br#"{"config":{"creditUsagePercent":10}}"#.to_vec(), }); - GrokCredentialQuotaService::new(repository, transport) + crate::support::grok_quota_service(repository, transport) .refresh_account(&id) .await .expect("refresh around concurrent cooldown"); @@ -578,7 +580,7 @@ async fn quota_refresh_rejects_a_concurrent_credential_revision() { }); assert!(matches!( - GrokCredentialQuotaService::new(repository, transport) + crate::support::grok_quota_service(repository, transport) .refresh_account(&id) .await, Err(GrokQuotaError::StaleCredentialSnapshot) @@ -593,7 +595,7 @@ async fn quota_projection_falls_back_to_legacy_monthly_usage() { let transport = QueueBillingTransport::success( br#"{"config":{"monthlyLimit":{"val":10000},"used":{"val":2500},"billingPeriodStart":"2026-07-01T00:00:00Z","billingPeriodEnd":"2026-08-01T00:00:00Z"}}"#, ); - let snapshot = GrokCredentialQuotaService::new(repository, transport) + let snapshot = crate::support::grok_quota_service(repository, transport) .refresh_account(&account_id("monthly-quota")) .await .expect("refresh monthly quota"); @@ -616,7 +618,7 @@ async fn quota_projection_preserves_unknown_period_for_dynamic_duration_fallback let transport = QueueBillingTransport::success( br#"{"config":{"creditUsagePercent":12.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_FORTNIGHT","start":"2026-07-01T00:00:00Z","end":"2026-07-15T00:00:00Z"}}}"#, ); - let snapshot = GrokCredentialQuotaService::new(repository, transport) + let snapshot = crate::support::grok_quota_service(repository, transport) .refresh_account(&account_id("dynamic-quota")) .await .expect("refresh dynamic quota"); @@ -642,7 +644,7 @@ async fn weekly_period_without_reported_allowance_is_not_authoritative_quota() { let transport = QueueBillingTransport::success( br#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-15T00:00:00Z","end":"2026-07-22T00:00:00Z"},"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"prepaidBalance":{"val":0}}}"#, ); - let snapshot = GrokCredentialQuotaService::new(repository, transport) + let snapshot = crate::support::grok_quota_service(repository, transport) .refresh_account(&account_id("free-quota")) .await .expect("refresh Free quota"); @@ -657,7 +659,7 @@ async fn reported_zero_percent_is_authoritative_quota() { let transport = QueueBillingTransport::success( br#"{"config":{"creditUsagePercent":0,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-15T00:00:00Z","end":"2026-07-22T00:00:00Z"}}}"#, ); - let snapshot = GrokCredentialQuotaService::new(repository, transport) + let snapshot = crate::support::grok_quota_service(repository, transport) .refresh_account(&account_id("zero-percent")) .await .expect("refresh reported quota"); @@ -672,7 +674,7 @@ async fn positive_prepaid_balance_is_authoritative_quota() { let transport = QueueBillingTransport::success( br#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-15T00:00:00Z","end":"2026-07-22T00:00:00Z"},"prepaidBalance":{"val":500}}}"#, ); - let snapshot = GrokCredentialQuotaService::new(repository, transport) + let snapshot = crate::support::grok_quota_service(repository, transport) .refresh_account(&account_id("prepaid-quota")) .await .expect("refresh prepaid quota"); @@ -694,7 +696,7 @@ async fn quota_read_rejects_corrupt_provider_document() { }) .await .expect("seed corrupt quota"); - let service = GrokCredentialQuotaService::new( + let service = crate::support::grok_quota_service( repository, QueueBillingTransport::success(br#"{"config":null}"#), ); @@ -714,7 +716,7 @@ async fn disabled_account_quota_refresh_never_calls_upstream() { .await .expect("disable account"); let transport = QueueBillingTransport::success(br#"{"config":null}"#); - let service = GrokCredentialQuotaService::new(repository, transport.clone()); + let service = crate::support::grok_quota_service(repository, transport.clone()); assert!(matches!( service.refresh_account(&account_id("disabled-quota")).await, @@ -727,11 +729,11 @@ async fn disabled_account_quota_refresh_never_calls_upstream() { async fn failed_quota_refresh_does_not_replace_last_good_observation() { let (store, repository) = repository_with_accounts(&[("stable", "subject-stable")]).await; let good = QueueBillingTransport::success(br#"{"config":{"creditUsagePercent":10}}"#); - GrokCredentialQuotaService::new(repository.clone(), good) + crate::support::grok_quota_service(repository.clone(), good) .refresh_account(&account_id("stable")) .await .expect("seed good observation"); - let service = GrokCredentialQuotaService::new(repository, QueueBillingTransport::failure()); + let service = crate::support::grok_quota_service(repository, QueueBillingTransport::failure()); assert!(matches!( service.refresh_account(&account_id("stable")).await, diff --git a/backend/crates/providers/xai/tests/credential/client.rs b/backend/crates/providers/xai/tests/credential/client.rs index 917642660..b3a649489 100644 --- a/backend/crates/providers/xai/tests/credential/client.rs +++ b/backend/crates/providers/xai/tests/credential/client.rs @@ -87,7 +87,12 @@ impl TokenVerifier for FixtureVerifier { #[tokio::test] async fn credential_import_should_require_exact_metadata_and_official_userinfo() { let transport = Arc::new(FixtureTransport::new(vec![response(200, DISCOVERY)])); - let client = GrokOAuthClient::new(config(), transport, Arc::new(FixtureVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport, + Arc::new(FixtureVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let now = chrono::Utc::now(); let candidate = GrokOAuthImportCandidate::new( @@ -151,7 +156,12 @@ async fn expired_import_should_refresh_then_require_authoritative_userinfo() { response(200, DISCOVERY), response(200, REFRESH_SUCCESS), ])); - let client = GrokOAuthClient::new(config(), transport.clone(), Arc::new(FixtureVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport.clone(), + Arc::new(FixtureVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let now = chrono::Utc::now(); let candidate = GrokOAuthImportCandidate::new( @@ -192,7 +202,12 @@ async fn expired_import_should_refresh_then_require_authoritative_userinfo() { #[tokio::test] async fn credential_import_should_reject_non_official_client_before_verification() { let transport = Arc::new(FixtureTransport::new(vec![response(200, DISCOVERY)])); - let client = GrokOAuthClient::new(config(), transport, Arc::new(FixtureVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport, + Arc::new(FixtureVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let now = chrono::Utc::now(); let candidate = GrokOAuthImportCandidate::new( @@ -227,7 +242,7 @@ fn response(status: u16, body: &[u8]) -> Result GrokOAuthConfig { - GrokOAuthConfig::official("0.2.101").expect("fixture config is valid") + GrokOAuthConfig::official().expect("fixture config is valid") } fn state_from_authorization_url(pending: &provider_xai::PendingAuthorization) -> String { @@ -244,7 +259,12 @@ async fn authorization_code_flow_should_verify_before_returning_tokens() { response(200, DISCOVERY), response(200, TOKEN_SUCCESS), ])); - let client = GrokOAuthClient::new(config(), transport.clone(), Arc::new(FixtureVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport.clone(), + Arc::new(FixtureVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let allowlist = RedirectUriAllowlist::new(["https://gateway.example/oauth/grok/callback"]) .expect("fixture allowlist is valid"); @@ -284,7 +304,12 @@ async fn authorization_code_flow_should_fail_closed_without_verifier() { response(200, DISCOVERY), response(200, TOKEN_SUCCESS), ])); - let client = GrokOAuthClient::new(config(), transport, Arc::new(FailClosedTokenVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport, + Arc::new(FailClosedTokenVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let allowlist = RedirectUriAllowlist::new(["https://gateway.example/oauth/grok/callback"]) .expect("fixture allowlist is valid"); @@ -318,7 +343,12 @@ async fn refresh_should_preserve_ambiguous_send_classification() { response(200, DISCOVERY), Err(TransportFailure::new(TransportFailureKind::Ambiguous)), ])); - let client = GrokOAuthClient::new(config(), transport, Arc::new(FailClosedTokenVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport, + Arc::new(FailClosedTokenVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let grant = RefreshTokenGrant::new(SecretValue::new( "fixture-refresh-token-not-usable".to_owned(), @@ -338,7 +368,12 @@ async fn refresh_should_return_rotated_token_without_id_token() { response(200, DISCOVERY), response(200, REFRESH_SUCCESS), ])); - let client = GrokOAuthClient::new(config(), transport, Arc::new(FailClosedTokenVerifier)); + let client = GrokOAuthClient::new( + config(), + crate::support::xai_wire_profile(), + transport, + Arc::new(FailClosedTokenVerifier), + ); let discovery = client.discover().await.expect("discovery succeeds"); let grant = RefreshTokenGrant::new(SecretValue::new( "fixture-refresh-token-not-usable".to_owned(), diff --git a/backend/crates/providers/xai/tests/credential/config.rs b/backend/crates/providers/xai/tests/credential/config.rs index ebc5d4b90..c205115d1 100644 --- a/backend/crates/providers/xai/tests/credential/config.rs +++ b/backend/crates/providers/xai/tests/credential/config.rs @@ -27,7 +27,7 @@ fn redirect_allowlist_should_reject_non_loopback_http_callback() { #[test] fn discovered_endpoint_should_reject_cross_origin_url() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let result = config.validate_discovered_endpoint("https://attacker.example/token"); @@ -39,7 +39,7 @@ fn discovered_endpoint_should_reject_cross_origin_url() { #[test] fn discovered_issuer_should_reject_query_components() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let result = config.validate_discovered_issuer("https://auth.x.ai?redirect=attacker"); diff --git a/backend/crates/providers/xai/tests/credential/discovery.rs b/backend/crates/providers/xai/tests/credential/discovery.rs index f2b8951d0..78cadaca5 100644 --- a/backend/crates/providers/xai/tests/credential/discovery.rs +++ b/backend/crates/providers/xai/tests/credential/discovery.rs @@ -4,7 +4,7 @@ const VALID_DISCOVERY: &str = include_str!("fixtures/discovery.json"); #[test] fn discovery_should_accept_official_origin_fixture() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let document = DiscoveryDocument::parse(&config, VALID_DISCOVERY.as_bytes()) .expect("official discovery fixture is valid"); @@ -15,7 +15,7 @@ fn discovery_should_accept_official_origin_fixture() { #[test] fn discovery_should_reject_cross_origin_token_endpoint() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let malicious = VALID_DISCOVERY.replace( "https://auth.x.ai/oauth2/token", "https://attacker.example/oauth2/token", @@ -29,7 +29,7 @@ fn discovery_should_reject_cross_origin_token_endpoint() { #[test] fn discovery_should_reject_none_signing_algorithm() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let insecure = VALID_DISCOVERY.replace("\"ES256\"", "\"none\""); let error = DiscoveryDocument::parse(&config, insecure.as_bytes()) @@ -40,7 +40,7 @@ fn discovery_should_reject_none_signing_algorithm() { #[test] fn discovery_should_reject_missing_signing_algorithms() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let incomplete = VALID_DISCOVERY.replace( "\"id_token_signing_alg_values_supported\": [\"ES256\"]", "\"id_token_signing_alg_values_supported\": []", @@ -54,7 +54,7 @@ fn discovery_should_reject_missing_signing_algorithms() { #[test] fn discovery_should_require_same_origin_userinfo_endpoint() { - let config = GrokOAuthConfig::official("0.2.101").expect("fixture config is valid"); + let config = GrokOAuthConfig::official().expect("fixture config is valid"); let malicious = VALID_DISCOVERY.replace( "https://auth.x.ai/oauth2/userinfo", "https://attacker.example/oauth2/userinfo", diff --git a/backend/crates/providers/xai/tests/credential/import.rs b/backend/crates/providers/xai/tests/credential/import.rs index 6f01f7d1b..015b24995 100644 --- a/backend/crates/providers/xai/tests/credential/import.rs +++ b/backend/crates/providers/xai/tests/credential/import.rs @@ -62,7 +62,8 @@ async fn expired_access_token_requires_official_refresh_before_identity_verifica let now = Utc::now(); let transport = Arc::new(RejectingRefreshTransport::default()); let client = GrokOAuthClient::new( - GrokOAuthConfig::official("0.2.101").expect("official config"), + GrokOAuthConfig::official().expect("official config"), + crate::support::xai_wire_profile(), transport.clone(), Arc::new(FailClosedTokenVerifier), ); @@ -120,7 +121,8 @@ async fn missing_required_scope_should_fail_closed() { async fn verified_import_inside_refresh_margin_should_schedule_immediate_refresh() { let now = Utc::now(); let client = GrokOAuthClient::new( - GrokOAuthConfig::official("0.2.101").expect("official config"), + GrokOAuthConfig::official().expect("official config"), + crate::support::xai_wire_profile(), Arc::new(DiscoveryTransport), Arc::new(AcceptingUserInfoVerifier), ); @@ -332,7 +334,8 @@ async fn real_oauth_accounts_should_cross_the_official_verification_boundary() { let expected_accounts = entries.len(); let endpoint_policy = Arc::new(OfficialGrokEndpointPolicy); let client = GrokOAuthClient::new( - GrokOAuthConfig::official("0.2.101").expect("official config"), + GrokOAuthConfig::official().expect("official config"), + crate::support::xai_wire_profile(), Arc::new( ReqwestOAuthTransport::new(endpoint_policy.clone()) .expect("production OAuth transport"), @@ -373,7 +376,8 @@ async fn real_oauth_accounts_should_cross_the_official_verification_boundary() { async fn verify(candidate: GrokOAuthImportCandidate) -> GrokOAuthImportError { let client = GrokOAuthClient::new( - GrokOAuthConfig::official("0.2.101").expect("official config"), + GrokOAuthConfig::official().expect("official config"), + crate::support::xai_wire_profile(), Arc::new(DiscoveryTransport), Arc::new(FailClosedTokenVerifier), ); diff --git a/backend/crates/providers/xai/tests/credential/refresh.rs b/backend/crates/providers/xai/tests/credential/refresh.rs index 8532317a5..0761c1b11 100644 --- a/backend/crates/providers/xai/tests/credential/refresh.rs +++ b/backend/crates/providers/xai/tests/credential/refresh.rs @@ -12,12 +12,12 @@ use gateway_core::provider_ports::{ ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderStoreError, }; use provider_xai::{ - GrokCredentialAdmin, GrokCredentialCatalogCache, GrokCredentialCatalogService, - GrokCredentialRecovery, GrokCredentialRecoveryOutcome, GrokCredentialRefreshError, - GrokCredentialRefreshOutcome, GrokCredentialRefreshService, GrokCredentialRefresher, - GrokCredentialRepository, GrokModelCatalogRequest, GrokModelCatalogTransport, - GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokRefreshFailure, - GrokRefreshTokens, RotateManagedGrokCredential, SecretValue, + GrokCredentialAdmin, GrokCredentialCatalogCache, GrokCredentialRecovery, + GrokCredentialRecoveryOutcome, GrokCredentialRefreshError, GrokCredentialRefreshOutcome, + GrokCredentialRefreshService, GrokCredentialRefresher, GrokCredentialRepository, + GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportFuture, + GrokModelCatalogTransportResponse, GrokRefreshFailure, GrokRefreshTokens, + RotateManagedGrokCredential, SecretValue, }; use crate::support::{ @@ -162,7 +162,7 @@ async fn fixture_many( let refresher = QueueRefresher::new(responses); let refresher_port: Arc = refresher.clone(); let cache: Arc = MemoryGrokCatalogCache::shared(); - let catalog = Arc::new(GrokCredentialCatalogService::new( + let catalog = Arc::new(crate::support::grok_catalog_service( repository.clone(), Arc::new(StaticCatalogTransport), cache, diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 366c1f414..40ca58c58 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -28,14 +28,14 @@ use gateway_core::routing::{ ProviderModel, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId, }; use provider_xai::{ - GROK_CLI_BASE_URL, GrokBuildProvider, GrokCredentialCatalogCache, GrokCredentialCatalogService, - GrokCredentialFailure, GrokCredentialFeedbackFuture, GrokCredentialRecovery, - GrokCredentialRecoveryOutcome, GrokCredentialRepository, GrokInferenceRequest, - GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError, - GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokModelCatalogRequest, - GrokModelCatalogTransport, GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, - GrokSessionBinding, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, - GrokSessionSelectorFuture, SecretValue, SelectedGrokSession, + GROK_CLI_BASE_URL, GrokBuildProvider, GrokCredentialCatalogCache, GrokCredentialFailure, + GrokCredentialFeedbackFuture, GrokCredentialRecovery, GrokCredentialRecoveryOutcome, + GrokCredentialRepository, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport, + GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, + GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportFuture, + GrokModelCatalogTransportResponse, GrokSessionBinding, GrokSessionSelection, + GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture, SecretValue, + SelectedGrokSession, }; use serde_json::{Map, json}; @@ -335,12 +335,18 @@ async fn provider_with_catalog_transport( .await .expect("catalog account"); let cache: Arc = MemoryGrokCatalogCache::shared(); - let catalog = Arc::new(GrokCredentialCatalogService::new( + let catalog = Arc::new(crate::support::grok_catalog_service( repository, catalog_transport, cache, )); - GrokBuildProvider::new(selector, transport, catalog, recovery) + GrokBuildProvider::new( + selector, + transport, + catalog, + recovery, + crate::support::xai_wire_profile(), + ) } async fn mapped_transport_error( @@ -1059,6 +1065,62 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco assert_eq!(selector.calls.load(Ordering::SeqCst), 3); } +#[tokio::test] +async fn missing_native_response_should_be_replay_safe_for_the_same_account() { + let transport = StubInferenceTransport::error( + GrokInferenceTransportError::new( + GrokInferenceTransportErrorKind::InvalidRequest, + UpstreamSendState::Sent, + ) + .with_status(404) + .with_upstream_code(SafeUpstreamValue::new("not_found").expect("safe code")), + ); + let provider = provider(StubSelector::success(), transport).await; + let state = ProviderSessionState::new( + "xai", + Map::from_iter([ + ("account_id".to_owned(), json!("acct_provider")), + ("session_id".to_owned(), json!("cache-session")), + ("transcript".to_owned(), json!([])), + ]), + ) + .expect("session state"); + let pin = NativeContinuationPin::new( + PreviousResponseId::new("resp_gateway_first").expect("response ID"), + SafeUpstreamValue::new("resp_upstream_first").expect("upstream response ID"), + ProviderKind::new("xai").expect("provider"), + instance_id(), + account_id("provider"), + NativeContinuationReuse::Reusable, + ); + let operation = operation_with_state( + json!({ + "model": "client-model", + "previous_response_id": "resp_gateway_first", + "input": [{"type":"message","role":"user","content":"second"}] + }), + state, + ); + let mut stream = provider + .execute( + provider_request_with_operation("xai", operation), + context( + CancellationToken::new(), + Some(ContinuationBinding::Pinned(pin)), + ), + ) + .await + .expect("native continuation stream"); + + let error = next_provider_error(&mut stream).await; + + assert_eq!( + error.continuation_failure(), + Some(ContinuationFailure::HistoryUnavailable) + ); + assert!(error.replay_is_safe()); +} + #[tokio::test] async fn cancellation_before_poll_never_calls_transport() { let transport = StubInferenceTransport::success(); diff --git a/backend/crates/providers/xai/tests/support.rs b/backend/crates/providers/xai/tests/support.rs index 4adb0655f..55703d78e 100644 --- a/backend/crates/providers/xai/tests/support.rs +++ b/backend/crates/providers/xai/tests/support.rs @@ -20,14 +20,49 @@ use gateway_core::provider_ports::{ use gateway_core::routing::ProviderInstanceId; use provider_xai::{ CreateGrokCredential, GROK_BILLING_URL, GrokAccountCatalog, GrokAccountProfile, - GrokCatalogCacheError, GrokCredentialAdmin, GrokCredentialAvailability, - GrokCredentialCatalogCache, GrokCredentialRepositoryError, GrokEndpointPolicy, GrokOAuthSecret, - GrokReqwestTransportBuildError, SecretValue, + GrokBillingTransport, GrokCatalogCacheError, GrokCredentialAdmin, GrokCredentialAvailability, + GrokCredentialCatalogCache, GrokCredentialCatalogService, GrokCredentialQuotaService, + GrokCredentialRepository, GrokCredentialRepositoryError, GrokEndpointPolicy, + GrokModelCatalogTransport, GrokOAuthSecret, GrokReqwestTransportBuildError, SecretValue, + XaiConfig, 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": { + "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() +} + +pub fn grok_catalog_service( + repository: GrokCredentialRepository, + transport: Arc, + cache: Arc, +) -> GrokCredentialCatalogService { + GrokCredentialCatalogService::new(repository, transport, cache, xai_wire_profile()) +} + +pub fn grok_quota_service( + repository: GrokCredentialRepository, + transport: Arc, +) -> GrokCredentialQuotaService { + GrokCredentialQuotaService::new(repository, transport, xai_wire_profile()) +} + #[derive(Debug, Clone, Copy)] pub struct LoopbackGrokEndpointPolicy { address: IpAddr, diff --git a/backend/crates/providers/xai/tests/transport/catalog.rs b/backend/crates/providers/xai/tests/transport/catalog.rs index 9e09df0ce..f448bc7a1 100644 --- a/backend/crates/providers/xai/tests/transport/catalog.rs +++ b/backend/crates/providers/xai/tests/transport/catalog.rs @@ -115,7 +115,7 @@ async fn client_should_send_exact_oauth_headers_without_api_key() { Some("xai-grok-cli"), Some("verified-user"), Some("person@example.com"), - Some("0.1.12"), + Some("0.2.106"), Some("headless"), Some("application/json"), None, @@ -480,7 +480,7 @@ fn session(email: Option<&str>) -> GrokModelCatalogSession { SecretValue::new("oauth-access".to_owned()), SecretValue::new("verified-user".to_owned()), email.map(|value| SecretValue::new(value.to_owned())), - "0.1.12", + crate::support::xai_wire_profile(), ) .expect("valid OAuth fixture") } diff --git a/backend/crates/providers/xai/tests/transport/failure_feedback.rs b/backend/crates/providers/xai/tests/transport/failure_feedback.rs index a0a080baa..6b6be150e 100644 --- a/backend/crates/providers/xai/tests/transport/failure_feedback.rs +++ b/backend/crates/providers/xai/tests/transport/failure_feedback.rs @@ -16,8 +16,8 @@ use provider_xai::{ GrokAccountCatalog, GrokAccountSessionSelector, GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokCredentialAdmin, GrokCredentialAvailability, GrokCredentialCatalogCache, - GrokCredentialCatalogSeed, GrokCredentialFailure, GrokCredentialQuotaService, - GrokCredentialRepository, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, + GrokCredentialCatalogSeed, GrokCredentialFailure, GrokCredentialRepository, + GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, RotateManagedGrokCredential, UpdateGrokCredentialState, }; @@ -137,7 +137,7 @@ impl SelectorFixture { }); let catalog_cache: Arc = cache.clone(); let lease_port: Arc = coordinator.clone(); - let quota = Arc::new(GrokCredentialQuotaService::new( + let quota = Arc::new(crate::support::grok_quota_service( repository.clone(), Arc::new(UnavailableBillingTransport), )); diff --git a/backend/crates/providers/xai/tests/transport/headers.rs b/backend/crates/providers/xai/tests/transport/headers.rs index d35930b51..a8cf9f74a 100644 --- a/backend/crates/providers/xai/tests/transport/headers.rs +++ b/backend/crates/providers/xai/tests/transport/headers.rs @@ -1,12 +1,9 @@ use gateway_core::engine::ModelRequestId; use gateway_core::engine::credential::{CredentialRevision, ProviderAccountId}; -use gateway_core::routing::{ - InstanceHealth, ProviderInstance, ProviderInstanceId, ProviderKind, UpstreamModelId, -}; +use gateway_core::routing::UpstreamModelId; use provider_xai::{ - GrokClientIdentity, GrokProviderInstanceConfig, GrokSessionBinding, SecretValue, - SelectedGrokSession, build_grok_headers, + GrokClientIdentity, GrokSessionBinding, SecretValue, SelectedGrokSession, build_grok_headers, }; use uuid::Uuid; @@ -23,21 +20,10 @@ fn selected_session() -> SelectedGrokSession { .expect("selected session") } -fn instance() -> GrokProviderInstanceConfig { - let instance = ProviderInstance::new( - ProviderInstanceId::new("inst_grok").expect("instance"), - ProviderKind::new("xai").expect("provider"), - "https://cli-chat-proxy.grok.com/v1".to_owned(), - true, - InstanceHealth::Healthy, - ); - GrokProviderInstanceConfig::from_snapshot(&instance).expect("compiled instance") -} - #[test] fn header_debug_should_redact_oauth_and_identity_values() { let headers = build_grok_headers( - &instance(), + &crate::support::xai_wire_profile(), &selected_session(), &GrokClientIdentity::new(), &ModelRequestId::new("req_grok_test").expect("request ID"), @@ -62,7 +48,7 @@ fn header_debug_should_redact_oauth_and_identity_values() { #[test] fn headers_should_bind_identity_to_the_selected_oauth_account() { let headers = build_grok_headers( - &instance(), + &crate::support::xai_wire_profile(), &selected_session(), &GrokClientIdentity::new(), &ModelRequestId::new("req_grok_identity").expect("request ID"), @@ -104,10 +90,33 @@ fn headers_should_bind_identity_to_the_selected_oauth_account() { ); } +#[test] +fn headers_should_keep_openai_cache_requests_out_of_shell_session_mode() { + let headers = build_grok_headers( + &crate::support::xai_wire_profile(), + &selected_session(), + &GrokClientIdentity::new(), + &ModelRequestId::new("req_grok_cache_route").expect("request ID"), + Some("session-fixture"), + None, + &UpstreamModelId::new("grok-code-test").expect("model"), + ); + let value = |name: &str| { + headers + .iter() + .find(|header| header.name().eq_ignore_ascii_case(name)) + .map(|header| header.value().expose()) + }; + + assert_eq!(value("x-grok-conv-id"), Some("session-fixture")); + assert_eq!(value("x-grok-session-id"), None); + assert_eq!(value("x-grok-turn-idx"), None); +} + #[test] fn headers_should_not_invent_session_identity_without_a_signal() { let headers = build_grok_headers( - &instance(), + &crate::support::xai_wire_profile(), &selected_session(), &GrokClientIdentity::new(), &ModelRequestId::new("req_grok_stateless").expect("request ID"), diff --git a/backend/crates/providers/xai/tests/transport/network.rs b/backend/crates/providers/xai/tests/transport/network.rs index d929a11e8..c62c064fd 100644 --- a/backend/crates/providers/xai/tests/transport/network.rs +++ b/backend/crates/providers/xai/tests/transport/network.rs @@ -363,7 +363,7 @@ async fn billing_transport_should_get_exact_credits_resource_without_redirect() SecretValue::new("access-token".to_owned()), SecretValue::new("user-id".to_owned()), None, - "0.2.101", + crate::support::xai_wire_profile(), ) .expect("billing session"); GrokBillingClient::new(transport) @@ -459,7 +459,8 @@ async fn official_oauth_transport_should_resolve_through_the_production_policy_w return; } let client = GrokOAuthClient::new( - GrokOAuthConfig::official("0.2.101").expect("official config"), + GrokOAuthConfig::official().expect("official config"), + crate::support::xai_wire_profile(), Arc::new( ReqwestOAuthTransport::new(Arc::new(OfficialGrokEndpointPolicy)) .expect("production OAuth transport"), diff --git a/backend/crates/providers/xai/tests/transport/request.rs b/backend/crates/providers/xai/tests/transport/request.rs index 2a2e04c7b..dc0ed920d 100644 --- a/backend/crates/providers/xai/tests/transport/request.rs +++ b/backend/crates/providers/xai/tests/transport/request.rs @@ -260,6 +260,68 @@ fn explicit_session_should_be_tenant_isolated_and_stable_across_turns() { ); } +#[test] +fn explicit_session_should_enable_the_noop_native_cache_route() { + let request = raw_request(json!({ + "model": "client", + "prompt_cache_key": "conversation-42", + "input": [{"role": "user", "content": "first"}] + })); + + let encoded = + GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request"); + let body = Value::Object(encoded.body().clone()); + + assert_eq!( + body.pointer("/tools"), + Some(&json!([{"type": "web_search"}, {"type": "x_search"}])) + ); + assert_eq!(body.pointer("/tool_choice"), Some(&json!("none"))); +} + +#[test] +fn explicit_session_should_preserve_a_client_tool_contract() { + let request = raw_request(json!({ + "model": "client", + "prompt_cache_key": "conversation-42", + "input": [{"role": "user", "content": "first"}], + "tools": [{ + "type": "function", + "name": "read_file", + "parameters": {"type": "object"} + }], + "tool_choice": "auto" + })); + + let encoded = + GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request"); + let body = Value::Object(encoded.body().clone()); + + assert_eq!( + body.pointer("/tools"), + Some(&json!([{ + "type": "function", + "name": "read_file", + "parameters": {"type": "object"} + }])) + ); + assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto"))); +} + +#[test] +fn soft_session_should_not_enable_the_native_cache_route() { + let request = raw_request(json!({ + "model": "client", + "instructions": "stable system", + "input": [{"role": "user", "content": "first prompt"}] + })); + + let encoded = + GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request"); + + assert!(!encoded.body().contains_key("tools")); +} + #[test] fn soft_session_should_follow_the_first_user_anchor() { let first = raw_request(json!({ diff --git a/deploy/README.md b/deploy/README.md index f652a6313..932d6771f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,12 +27,13 @@ openssl rand -hex 24 把结果写入 `deploy/config.yaml`: -- `x-cpr.database.password` -- `x-cpr.redis.password` -- `x-cpr.admin.default_password` +- `store.database.password` +- `store.redis.password` +- `admin.default_password` PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。管理员初始化密码至少 12 位、不能是 -常见弱口令且不能包含 `$`。三个密码不会通过环境变量覆盖,也不能嵌入连接 URL。 +常见弱口令且不能包含 `$`。Compose 通过 `config.yaml` 的凭据桥接区引用同一密码,三个密码 +都不需要额外导出为环境变量,也不能嵌入连接 URL。 Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录: @@ -88,9 +89,9 @@ Compose 使用以下绑定目录: 普通 `docker compose down` 不会删除这些目录。删除 `.runtime` 会永久清除本地状态。 -本架构从全新的 `0001_initial.sql` 起步,不支持把旧版本数据库原地升级到当前结构,也不提供 -旧表或旧 Redis 数据的兼容读取。首次启动必须使用空的 PostgreSQL 数据库和空 Redis;需要保留 -旧环境时应独立保存其目录,不能让新旧二进制共享同一份数据目录。 +PostgreSQL 由 `0001_initial.sql` 创建当前业务结构,Redis 保存可丢失的协调状态。 + +完整运行时、Provider、revision 与恢复边界见 [架构文档](../docs/architecture.md)。 ## Credential 加密 keyring diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 35555a589..cc559c1fe 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -8,6 +8,9 @@ x-json-logging: &json-logging services: postgres: + extends: + file: config.yaml + service: postgres-credentials image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818 restart: unless-stopped command: @@ -20,7 +23,6 @@ services: TZ: Asia/Shanghai POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy - POSTGRES_PASSWORD: ${CPR_DATABASE_PASSWORD:?CPR_DATABASE_PASSWORD is required} POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 volumes: - type: bind @@ -40,14 +42,11 @@ services: logging: *json-logging redis: + extends: + file: config.yaml + service: redis-credentials image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005 restart: unless-stopped - command: - - redis-server - - --appendonly - - 'yes' - - --requirepass - - ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD is required} volumes: - type: bind source: ../.runtime/redis @@ -57,13 +56,6 @@ services: ports: - '127.0.0.1:6379:6379' healthcheck: - test: - - CMD - - redis-cli - - --no-auth-warning - - -a - - ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD is required} - - ping interval: 5s timeout: 5s retries: 10 @@ -128,8 +120,6 @@ services: CPR_SERVER_PORT: '8080' CPR_DATABASE_URL: postgres://codex_proxy@postgres:5432/codex_proxy CPR_REDIS_URL: redis://redis:6379/ - CPR_DATABASE_PASSWORD: ${CPR_DATABASE_PASSWORD:?CPR_DATABASE_PASSWORD is required} - CPR_REDIS_PASSWORD: ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD is required} CPR_DEPLOYMENT_MODE: docker CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs CPR_UPDATE_CHANNEL: stable diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index aedb30219..8bd9a6a9c 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -31,11 +31,11 @@ store: database: # Docker 内由 CPR_DATABASE_URL 覆盖;密码不要写进 URL。 url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy' - password: '' + password: &postgres_password '' redis: # Docker 内由 CPR_REDIS_URL 覆盖;密码不要写进 URL。 url: 'redis://127.0.0.1:6379/' - password: '' + password: &redis_password '' admin: session_ttl_minutes: 1440 @@ -54,13 +54,43 @@ openai: wire_profile: # 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。 originator: 'Codex Desktop' - codex_version: '0.144.2' - desktop_version: '26.707.72221' - desktop_build: '5307' + codex_version: '0.144.6' + desktop_version: '26.715.61943' + desktop_build: '5628' os_type: 'Mac OS' os_version: '15.7.1' arch: 'arm64' terminal: 'unknown' - verified_at: '2026-07-15T02:25:50+08:00' + verified_at: '2026-07-21T00:00:00+08:00' -xai: {} +xai: + wire_profile: + # 对照 grok2api 当前推荐构建核验;更新前应重新抓包确认。 + 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' + +# Compose 凭据桥接区;密码仍只维护在 store 中。 +services: + postgres-credentials: + environment: + POSTGRES_PASSWORD: *postgres_password + + redis-credentials: + command: + - redis-server + - --appendonly + - 'yes' + - --requirepass + - *redis_password + healthcheck: + test: + - CMD + - redis-cli + - --no-auth-warning + - -a + - *redis_password + - ping diff --git a/docs/architecture.md b/docs/architecture.md index d27d40978..c3ede9f83 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,157 +1,214 @@ -# Codex Proxy RS 当前架构 +# Codex Proxy RS 架构 -本文只描述仓库当前生效的终态。设计依据是 -[多 Provider 目标架构](multi-provider-architecture.md) 与 -[终态数据模型](multi-provider-database.md);旧账号池、旧 dispatch、旧 telemetry 和迁移期兼容路径不属于当前系统。 +本文是仓库唯一架构文档,只描述当前架构。 -## 系统边界 +## 1. 系统边界 -Codex Proxy RS 是单进程、多 Provider 的透明 AI 网关: +Codex Proxy RS 是单进程、可多副本部署的多 Provider AI 网关: -- 客户端面提供 `POST /v1/responses`、`GET /v1/models` 和模型详情。 -- 控制面提供 `/api/admin/*` 与 Vue 管理端静态资源。 -- 当前注册 Provider 为 Codex 和 xAI;两者都使用 OAuth credential,xAI 不接受 API Key 模式。 -- PostgreSQL 保存配置、请求、Attempt、价格、延续和审计等权威事实。 -- Redis 只保存可恢复的 admission、lease、cooldown、OAuth pending flow 与配置通知。 -- `.runtime/` 保存本地密钥材料、日志和部署数据,不把秘密写入源码或普通配置 JSON。 +- 客户端面提供 OpenAI Responses 兼容的 JSON、SSE、WebSocket 与模型目录协议。 +- 管理面提供 `/api/admin/*` 和 Vue 静态管理端。 +- 当前 Provider 为 OpenAI 与 xAI;账号 credential 均由所属 Provider 独占解释。 +- OpenAI 行为以重构前正式实现为语义基准;xAI/Grok 行为以 `grok2api` 验证结果为基准。 +- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI CLI、OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。 +- OpenAI CLI 使用官方 npm 包 `@openai/codex`,OpenAI Desktop 使用官方 appcast,xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。 +- PostgreSQL 是业务与配置事实的唯一权威存储。 +- Redis 只保存可丢失、可重建或有自然过期时间的协调状态。 -客户端协议、Gateway Engine 与 Provider adapter 相互独立。OpenAI Responses handler 不识别 Codex/xAI;Provider 不拥有调用方预算和跨平台路由策略。 +Gateway Engine 不识别具体 Provider 协议;Provider 不拥有客户端 admission、跨 instance 路由或业务重试预算。 -## Workspace 与依赖方向 +## 2. Workspace 与依赖方向 ```text backend/ -├── apps/gateway/ composition root、HTTP、任务 -├── crates/gateway-core/ operation、routing、engine、policy、accounting -├── crates/gateway-api/ OpenAI Responses 边界适配 -├── crates/gateway-protocol/ 可共享 wire contract -├── crates/gateway-store/ PostgreSQL/Redis adapter -├── crates/providers/codex/ Codex Provider 与 credential owner -├── crates/providers/xai/ xAI/Grok OAuth session Provider -├── migrations/0001_initial.sql 唯一初始迁移 -└── Cargo.toml +├── apps/gateway/ composition root、HTTP server、后台任务 +├── crates/gateway-core/ operation、routing、engine、policy、accounting +├── crates/gateway-protocol/ 可共享 wire contract 与 canonical event +├── crates/gateway-admin/ 管理领域、用例与抽象端口 +├── crates/gateway-store/ PostgreSQL、Redis adapter +├── crates/gateway-api/ OpenAI Responses 与 Admin HTTP adapter +├── crates/gateway-host/ host/update/system 能力 +├── crates/providers/openai/ OpenAI credential、catalog、transport +├── crates/providers/xai/ xAI/Grok credential、catalog、transport +└── migrations/0001_initial.sql 唯一终态初始迁移 ``` -依赖方向固定为: +依赖规则: + +- `gateway-core` 不依赖 HTTP、数据库、Redis 或具体 Provider。 +- `gateway-protocol` 不依赖其他 workspace crate。 +- Provider crate 之间禁止互相依赖。 +- `gateway-api` 只面向 Admin/Core/Protocol 抽象,不导入具体 Provider。 +- `gateway-store` 实现 Admin/Core 端口,但不拥有业务策略。 +- 只有 `apps/gateway` 组合具体实现。 + +各 crate 的 architecture 测试强制验证 crate 集合、依赖 DAG、生产源码边界与测试树。 + +## 3. 请求执行链 ```text -gateway binary -├── gateway-api -------> gateway-core -├── gateway-store -----> gateway-core -├── provider-codex ----> gateway-core -└── provider-xai ------> gateway-core +API decode/auth + -> freeze RuntimeSnapshot + -> compile RoutePlan + -> admission + -> persist model_request + -> select Provider instance/account + -> persist attempt facts in model_request + -> cross upstream send barrier + -> canonical stream + -> persist downstream delivery barrier + -> terminal observation and accounting ``` -`gateway-core` 不依赖 Axum、SQLx、Redis、reqwest 或具体 Provider。Provider crate 之间禁止互相依赖;只有 bootstrap 可以把具体 adapter 绑定到抽象边界。 +核心不变量: -## 数据面 +1. 每个客户端请求只有一条 `model_requests`;attempt 明细保存在其 JSON 事实中,不另建 attempt 表。 +2. 任何可能到达上游的调用都必须先有持久请求事实。 +3. `not_sent`、`sent`、`ambiguous` 是不同的上游发送边界;`ambiguous` 不自动重放。 +4. `downstream_committed_at` 表示网关已作出不可撤回的交付承诺,从该时刻禁止 retry/fallback;它在实际首字节写出前持久化,不宣称字节已经到达客户端。 +5. Provider 每次 `execute` 只能选择一个 credential 并准备一个 cold stream,不得隐藏换号或业务 retry。 +6. 下游 commit 前,Core 可按冻结策略处理同 instance 账号 fallback 和同 Provider kind 的 instance fallback;禁止跨 Provider kind fallback。 +7. 本架构不通过隐式连接复用承载业务身份。HTTP client 可安全复用 transport 连接,但账号、credential revision、cookie/session binding 必须显式绑定到本次调用。 -一次请求只有一个 `gateway_requests`,所有可能到达上游的调用分别对应有序 `request_attempts`: +## 4. 路由、fallback 与错误处理 -```text -API decode - -> authenticate client key + freeze RuntimeSnapshot - -> resolve native/portable continuation - -> compile RoutePlan + admission reservation - -> persist logical request - -> Provider selects credential and returns cold stream - -> persist Attempt - -> mark ambiguous immediately before first poll/send - -> canonical events - -> downstream commit barrier - -> terminal CAS + usage/cost/bucket/continuation transaction -``` +RuntimeSnapshot 冻结 Provider instance、模型能力、账号候选、运行时策略和全局 `config_revision`。一次请求始终使用同一快照,不在执行中拼接新旧配置。 -不变量: +Fallback 层级固定为: -1. Logical request 与 Attempt 必须先提交 PostgreSQL,才允许发送可能计费的 payload。 -2. `upstream_send_state` 与 `downstream_committed` 是独立边界。 -3. `not_sent` 和已经收到明确失败响应的 `sent` 可在下游 commit 前按策略 retry;`ambiguous` 禁止自动重放。 -4. 同一 request 最多一个 committed Attempt,commit 后不能新建 Attempt。 -5. Provider transport 不隐藏 credential retry、payload retry 或跨 origin redirect。 -6. 请求级 RoutePlan、credential revision、service tier 与价格时间线均来自同一冻结 Snapshot。 +1. 同一 Provider instance 内更换可用账号。 +2. 同一 Provider kind 内切换 instance。 +3. 不跨 Provider kind。 -### 路由与价格 +明确的上游认证、封禁、额度或 cooldown 错误会更新对应账号状态,使下一次选择排除该账号;满足重放安全条件且尚未 downstream commit 时,可以换号。传输结果不明确时不得假定请求未到达上游。 -Router 先按 operation、capability、健康、地区和调用方 allowlist 过滤,再按 route policy 排序。Target 只引用 Provider instance 与上游模型,不固定 credential。 +重试矩阵属于独立策略,本次终态迁移保持 OpenAI 原正式行为与 xAI 参考行为,不在 Provider transport 内增加额外重试。 -每个 Attempt 按自己的 `started_at` 从冻结价格时间线选择 `effective_from <= started_at` 的最新版本。`not_sent` 阶段不写价格;跨越 send barrier 时同时冻结 price version ID,终态使用十进制定点计算 known/partial/unknown 成本。零价必须由显式零费率表达。 +## 5. Continuation -Strict budget 只保留同币种且可计算完整保守上界的 target,按输入估算、最大输出和最大 attempts 预留。终结时使用全部 Attempts 的 known 成本结算;任何 partial/unknown 都不会按零释放。Redis epoch 丢失时先从持久事实重建,再恢复 admission。 +系统只保存已被上游确认的安全外部 ID:`model_requests.upstream_response_id`,不新增 conversation、transcript、continuation 或 claim 表。 -### Continuation +- `store=true`:使用 Provider 持久化的 native handle。 +- `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。 +- OpenAI continuation 顺序为 native、replay owner、replay any。 +- xAI 使用客户端提交的完整历史作为已验证 continuation 路径。 +- continuation 失败仍受 send barrier、downstream commit 和 Provider kind 边界约束。 -- Native continuation 固定 Provider、instance 和必要 credential scope;single-use claim/consume/ambiguous 由 PostgreSQL CAS 保证。 -- Portable continuation 使用调用方隔离的加密 transcript snapshot 链,可跨 Provider 路由;输入、可表示的成功输出和 binding 与 terminal request/Attempt 在同一事务提交,失败与取消不生成正文。 -- Redis 只协助短期串行化,flush 不会让已经可能消费的 native handle 再次可用。 -- 终态 continuation、最终 Attempt 与 request 在同一事务完成后,API 才能发送 terminal event。 +## 6. Provider 与 credential owner -## Provider 边界 +`Provider` 接收 canonical `Operation + ProviderCandidate + AttemptContext`,返回携带冻结 metadata 的 canonical cold stream。Registry 使用稳定 `ProviderKind` 查找实现。 -`Provider` 接收 canonical `Operation + PlannedTarget + AttemptContext`,返回带冻结 metadata 的 cold canonical stream。Gateway Engine 只通过 Registry 查找 Provider,不写平台分支。 +Provider 独占以下职责: -Credential 由对应 Provider 独占管理: +- credential 文档的编码、解码与校验; +- OAuth 登录、导入、刷新与轮换; +- 账号选择所需的 Provider 私有事实; +- catalog 查询与能力编译; +- HTTP/WebSocket transport 和错误分类; +- quota 投影与 Provider 私有观测字段。 -- Codex:OAuth token、ChatGPT 可查询字段、Cookie policy、credential selector 与 Codex transport。 -- xAI:Device/Authorization Code OAuth、OIDC 校验、加密 session token、Grok inference transport。 +credential 更新使用 `credential_revision` CAS。认证永久失败、封禁、额度耗尽和带截止时间的 cooldown 是账号运行时事实。cooldown 到期后,Core 的有效调度谓词会自然允许账号重新参与选择;Redis cooldown 只是热缓存。成功调用后才可把账号状态重新观测为 `ready`。 -Provider selector 同时冻结 `credential_revision` 与 `state_revision`。明确的认证失败、额度耗尽和 429 分别写入 `invalid`、`exhausted` 或带截止时间的 `cooldown`;更新使用 credential/state 双 revision CAS 与 `observed_at`,因此旧请求不能污染已经轮换的新 secret。普通成功 Attempt 不回写 credential state。 +`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。 -Secret envelope、Cookie、PKCE verifier、device code 和 native state 使用用途/owner/行身份绑定的 AEAD;日志、Debug、Admin API 与 audit details 不输出秘密或完整身份。 +## 7. 控制面与 revision -## 控制面 +管理写入使用 `expected_config_revision` 做全局乐观并发控制。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中: -控制面管理 client key、Provider instance/model、model route/target、不可变 price version,以及 Codex/xAI OAuth credential。 +- 校验 expected revision; +- 执行业务 mutation; +- 推进 `runtime_settings.config_revision`; +- 写入脱敏 `admin_audit_events`。 -所有 Admin HTTP 契约遵守: +推进全局 revision 的 mutation 包括 runtime settings、Provider instance、客户端 Key、账号导入/创建/删除、管理员显式启停和管理员 credential rotation。 -- 只使用 GET 与 POST。 -- ID 放在 query params 或 JSON body,不放动态 URL path。 -- 结构变更携带 `expectedConfigRevision`。 -- 业务写入、单调 `config_revision` 和脱敏 audit event 位于同一 PostgreSQL 事务。 -- OAuth token rotation 只推进 `credential_revision`,高频 availability/cooldown 只推进 `state_revision`。 +不推进全局 revision 的运行时观测包括 quota、cooldown、catalog generation、请求统计以及自动 credential refresh;自动 refresh 只推进 `credential_revision`。提交后 Redis 通知只负责缩短其他副本的收敛延迟,周期性 PostgreSQL 对账才是正确性基础。 -Provider instance 的 create/update/enable 在 PostgreSQL mutation 前先由当前已注册 adapter 校验 endpoint 和公开 options;未知 Provider 或非法配置不会推进 revision。提交成功后,控制面通过带 namespace 的 Redis Pub/Sub v1 通知广播 config/credential/state revision;credential ID 在进入 payload 前只保留 SHA-256 指纹,通知不携带 secret 或资源原文。通知只唤醒一次完整 PostgreSQL reload,解析失败、断线、乱序或发布失败都不会拼接局部状态,也不会改变已经提交的 PostgreSQL 结果。 +## 8. PostgreSQL 终态 -RuntimeSnapshot 周期对账始终每 5 秒比较全局 `config_revision` 和 credential `(credential_revision, state_revision)` 向量,Redis 通知仅缩短收敛延迟。对账允许 config revision 跨多个版本并直接重载最新一致快照;任何 revision 回退、credential 集合无结构版本变化或候选快照无效都会保持 fail closed。 +`backend/migrations/0001_initial.sql` 创建且只创建八张业务表: -Price version 发布后不可更新或删除修正;更正必须发布新版本。 - -## 持久化 owner - -| 事实 | 唯一 owner | +| 表 | 权威事实 | | --- | --- | -| client key、runtime settings | control-plane settings | -| Provider instance/model/route/price | config publisher | -| upstream credential secret/config | 对应 Provider credential manager | -| credential availability/cooldown | Provider state owner | -| request/attempt | Gateway execution lifecycle | -| transcript/binding | history/continuation owner | -| ops event | ops recorder | -| request/attempt metric bucket | bucket aggregator | +| `admin_users` | 管理员身份与密码摘要 | +| `admin_audit_events` | 管理 mutation 审计 | +| `client_api_keys` | 客户端鉴权、限额与授权范围 | +| `runtime_settings` | 全局配置与 `config_revision` | +| `provider_instances` | Provider endpoint 与公开配置 | +| `provider_accounts` | 账号资料、加密 credential、revision、quota、cooldown | +| `model_requests` | 请求、attempt、计费、交付与恢复事实 | +| `ops_events` | 脱敏运行事件 | -`backend/migrations/0001_initial.sql` 直接创建终态 schema。应用只接受空业务 schema,不包含旧表 backfill、dual-write、旧字段读取或兼容 migration。可表达的关系全部使用真实 FK,并为 FK 子列提供支持索引;外部协议 ID、删除后保留的 `_ref` 与多态 audit ref 是明确例外。 +设计规则: -## Redis epoch 与后台任务 +- 一个事实只存一次;可表达关系使用真实 FK 与支持索引。 +- Provider 差异只进入受 schema/version 校验的 JSONB 边界。 +- secret 只保存加密 envelope;日志、Debug、API 和 audit 不输出 secret。 +- stale recovery 只把超时 `running` 请求收敛为 `incomplete`,不重放业务请求。 +- `ops_events` 当前逐条同步持久化,`occurrence_count=1`;OpsFlush 可禁用,不存在隐藏内存聚合权威。 +- retention 只删除已满足保留规则的历史事实,不改变运行中请求。 -Redis 空状态不等于零使用量: +业务 schema 由唯一初始迁移 `0001_initial.sql` 定义。 -- client admission epoch 从 running/近期 request 与 attempt 成本事实重建。 -- Provider/credential lease epoch 从 running Attempt 和存活 worker 的 live guard 重建。 -- epoch 为 missing/rebuilding 时,受限 admission 与 lease acquire 均 fail closed。 -- Lua 使用 Redis server time、hash tag 和 fencing/epoch 检查完成原子操作。 +## 9. Redis 终态 -后台任务还负责 RuntimeSnapshot revision 对账、stale execution recovery、closed bucket rebuild 与 retention。Retention 固定先 recovery,再清 binding/transcript,最后清 request/attempt 和过期 bucket。 +Redis 保存 admission、lease、circuit/cooldown 热状态、OAuth pending flow 与 runtime change 通知。Redis 丢失后必须从 PostgreSQL 或 Provider 事实恢复;恢复完成前需要保护的 acquire 路径 fail closed。 -## 前端与测试 +OAuth pending key: -前端复用旧项目的克制视觉语言,但信息架构只面向终态控制面。外部响应在 API/composable 边界收窄;局部状态与返回值以推导为主,不维护与后端 DTO 镜像的显式类型层。 +```text +codex-proxy-rs:oauth-pending:v1:{provider_kind}:{flow_fingerprint} +flow_fingerprint = SHA-256(provider_kind || 0x00 || raw_flow_binding) +``` -测试全部位于对应 package 的 `tests/`,目录结构镜像 `src/`。生产 `src` 不包含 `#[cfg(test)]`、测试模块挂载或 test-only 分支;架构测试会强制检查该约束: +Hash 字段恰好为: -- core 规则使用快速单元测试。 -- Provider 使用 contract suite 覆盖流、错误、取消、secret redaction。 -- FK、CAS、revision、continuation、price、retention 和 rebuild 使用真实 PostgreSQL。 -- admission、lease、OAuth pending flow 与 epoch recovery 使用真实 Redis,必要时联合真实 PostgreSQL。 +- `owner_fingerprint` +- `expires_at_epoch_seconds` +- `provider_payload` -架构验收以两个权威文档为准;若新增普通 Provider 仍需要修改 Gateway Engine、OpenAI handler 或通用 schema,说明边界已经回退。 +create/take 使用 Lua 原子执行。owner mismatch 不删除记录;匹配 owner 的 take 返回 payload 并一次性删除。OpenAI TTL 为 10 分钟,xAI TTL 为 30 分钟。 + +## 10. 后台任务与恢复 + +后台任务由 Provider bundle 和 host bundle 显式贡献,bootstrap 统一启动: + +- RuntimeSnapshot revision 对账与 Redis change wake-up; +- credential refresh、catalog、quota 等 Provider worker; +- stale execution recovery; +- retention 与必要的 Redis 热状态重建; +- host update/system worker。 + +worker 不得通过导入 Admin use case 绕过边界,也不得维护第二份业务状态。 + +## 11. 测试与验收 + +- 生产 `src` 禁止 `#[cfg(test)]`、`#[path]` 和 `include!` 测试挂载。 +- 测试位于各 package 的 `tests/`,目录镜像生产模块。 +- Core 规则使用确定性测试;Provider 使用 contract/fixture 测试。 +- FK、CAS、revision、recovery 使用真实 PostgreSQL 测试。 +- admission、lease、cooldown、OAuth pending 使用真实 Redis 测试。 +- 真实账号测试只从仓库外部文件读取,不打印、复制或提交凭证。 + +真实对话测试是显式 ignored 的生产边界测试,必须依次穿过 Provider 导入/必要刷新、实时 catalog、账号 selector、生产 HTTP/SSE transport、canonical 解码、usage 与 completed 终态。xAI 用例允许 OAuth refresh token 轮换,因此必须额外显式授权: + +```bash +CODEX_REAL_ACCOUNT_FIXTURE=/outside/cpr-accounts.json \ + cargo test -p provider-openai --test main \ + admin::real_openai_conversation_crosses_production_provider_boundaries \ + -- --ignored --exact + +XAI_REAL_ACCOUNT_FIXTURE=/outside/grok-accounts.json \ +XAI_ALLOW_DESTRUCTIVE_FIXTURE_REFRESH=1 \ + cargo test -p provider-xai --test main \ + admin::real_xai_conversation_crosses_production_provider_boundaries \ + -- --ignored --exact +``` + +终态门禁: + +```bash +cd backend +cargo fmt --all -- --check +cargo test --workspace --all-targets +``` diff --git a/frontend/src/views/dashboard/components/WireProfileCard.vue b/frontend/src/views/dashboard/components/WireProfileCard.vue index 893307c07..413c472c7 100644 --- a/frontend/src/views/dashboard/components/WireProfileCard.vue +++ b/frontend/src/views/dashboard/components/WireProfileCard.vue @@ -62,8 +62,8 @@ const releaseStatus = computed(() => { const current = profile.value if (!current?.release) { return { - label: '官方固定画像', - title: '当前 Provider 使用官方客户端固定画像', + label: '当前生效', + title: '当前 Provider 请求正在使用此运行时画像', tone: 'bg-(--cp-info-bg) text-(--cp-info-text)', icon: ShieldCheck, } @@ -73,7 +73,7 @@ const releaseStatus = computed(() => { if (status === 'aligned') { return { label: '制品一致', - title: '当前 Desktop 版本与官方发布一致', + title: '当前生效版本与官方最新发布一致', tone: 'bg-(--cp-success-bg) text-(--cp-success-text)', icon: CheckCircle2, } @@ -89,14 +89,14 @@ const releaseStatus = computed(() => { if (status === 'check_failed') { return { label: '检查失败', - title: current.release.error || 'Desktop 发布检查失败', + title: current.release.error || '官方版本检查失败', tone: 'bg-(--cp-danger-bg) text-(--cp-danger-text)', icon: TriangleAlert, } } return { label: '待检查', - title: '尚未检查 Desktop 官方发布', + title: '尚未检查官方发布渠道', tone: 'bg-(--cp-normal-bg) text-(--cp-normal-text)', icon: RefreshCw, } diff --git a/skills/codex-proxy-rs/SKILL.md b/skills/codex-proxy-rs/SKILL.md index 2efc6bb72..83343c642 100644 --- a/skills/codex-proxy-rs/SKILL.md +++ b/skills/codex-proxy-rs/SKILL.md @@ -16,11 +16,10 @@ description: Codex Proxy RS 仓库开发指南。Use when changing or auditing i ## 边界 -- `v1/*` 使用 Request → Attempt → Stream 生命周期。功能规则只能留在唯一 owner 内。 -- `api` 解析和编码;`dispatch` 编排;`fleet` 管账号;`upstream` 管协议与传输;`telemetry` 保存确定事实。 -- transport 不选择账号,不解释账号状态,不在 payload 发送后重放。 -- PostgreSQL 是持久化权威;Redis 保存会话、租约、模型快照和短期状态。 -- 测试只放 `backend/tests/`。禁止在 `backend/src/` 写 test-only 代码。 +- `gateway-api` 只做协议适配;`gateway-core` 编排;Provider 独占 credential、catalog 与 transport;`gateway-store` 实现持久化端口。 +- Provider 每次调用只选择一个 credential,不隐藏换号、业务 retry 或跨 Provider fallback。 +- PostgreSQL 是持久化权威;Redis 只保存可恢复协调状态和 OAuth pending flow。 +- 测试放在各 package 的 `tests/`。禁止在生产 `src/` 写 test-only 代码。 - Vue 使用 `