From 057c95b0eaaccadc54cb63b7096a8fa4b2a6d376 Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 21 Jul 2026 19:06:13 +0800 Subject: [PATCH] fix(gateway): finalize provider runtime behavior Restore provider-owned identity profiles, credential refresh, cache routing, model catalogs, and runtime configuration across OpenAI and xAI without compatibility paths. Keep deployment configuration, frontend visibility, and architecture documentation aligned with the finalized multi-provider boundary. Co-authored-by: Codex --- README.md | 29 +- backend/Cargo.lock | 1 + backend/apps/gateway/src/bootstrap.rs | 3 + backend/apps/gateway/tests/bootstrap.rs | 14 +- .../gateway-store/src/redis/oauth_pending.rs | 53 ++-- .../tests/postgres/provider_accounts.rs | 14 +- .../tests/redis/oauth_pending.rs | 84 +++++- backend/crates/providers/openai/src/lib.rs | 13 +- .../crates/providers/openai/src/provider.rs | 35 ++- .../providers/openai/src/transport/profile.rs | 107 +++++++ .../crates/providers/openai/tests/admin.rs | 168 ++++++++++- backend/crates/providers/xai/Cargo.toml | 1 + backend/crates/providers/xai/src/admin.rs | 31 +- backend/crates/providers/xai/src/config.rs | 68 ++++- .../providers/xai/src/credential/catalog.rs | 31 +- .../providers/xai/src/credential/client.rs | 5 +- .../providers/xai/src/credential/config.rs | 28 +- .../providers/xai/src/credential/error.rs | 3 - .../providers/xai/src/credential/refresh.rs | 1 - backend/crates/providers/xai/src/lib.rs | 30 +- backend/crates/providers/xai/src/provider.rs | 50 +++- .../providers/xai/src/transport/catalog.rs | 35 +-- .../providers/xai/src/transport/config.rs | 12 +- .../providers/xai/src/transport/headers.rs | 30 +- .../crates/providers/xai/src/transport/mod.rs | 7 +- .../providers/xai/src/transport/profile.rs | 193 +++++++++++++ .../providers/xai/src/transport/request.rs | 29 ++ backend/crates/providers/xai/tests/admin.rs | 226 +++++++++++++-- backend/crates/providers/xai/tests/config.rs | 17 +- .../tests/credential/authorization_code.rs | 2 +- .../providers/xai/tests/credential/catalog.rs | 64 +++-- .../providers/xai/tests/credential/client.rs | 51 +++- .../providers/xai/tests/credential/config.rs | 4 +- .../xai/tests/credential/discovery.rs | 10 +- .../providers/xai/tests/credential/import.rs | 12 +- .../providers/xai/tests/credential/refresh.rs | 14 +- .../providers/xai/tests/provider/contract.rs | 82 +++++- backend/crates/providers/xai/tests/support.rs | 41 ++- .../providers/xai/tests/transport/catalog.rs | 4 +- .../xai/tests/transport/failure_feedback.rs | 6 +- .../providers/xai/tests/transport/headers.rs | 47 +-- .../providers/xai/tests/transport/network.rs | 5 +- .../providers/xai/tests/transport/request.rs | 62 ++++ deploy/README.md | 15 +- deploy/compose.yaml | 22 +- deploy/config.example.yaml | 44 ++- docs/architecture.md | 271 +++++++++++------- .../dashboard/components/WireProfileCard.vue | 10 +- skills/codex-proxy-rs/SKILL.md | 9 +- .../codex-proxy-rs/references/repo-guide.md | 131 +++------ 50 files changed, 1684 insertions(+), 540 deletions(-) create mode 100644 backend/crates/providers/xai/src/transport/profile.rs 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 使用 `