Compare commits

...
23 Commits
Author SHA1 Message Date
zyycnandCodex afb68e97d6 chore: bump version to 3.0.7
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:55:44 +08:00
zyycnandCodex 93b9741af2 fix(update): preserve streamed log history
Include each update event ID in the SSE JSON payload so the frontend appends distinct progress rows instead of replacing one undefined-ID row.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:55:29 +08:00
zyycnandCodex 013d592454 chore: bump version to 3.0.6
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:35:54 +08:00
zyycnandCodex 0f56e9b80c feat(ui): replace product branding icon
Use the new product mark across the favicon, login panel, and sidebar with a consistent dark brand background and cache-busted browser icon.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:34:57 +08:00
zyycnandCodex 6ec5ecb9ac fix(ui): preserve numeric input model values
Keep number inputs on the string model contract so settings edits reach form state and persist after refresh.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:34:28 +08:00
zyycnandCodex 460d439351 fix(ui): stabilize button loading spinner
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:50:16 +08:00
zyycnandCodex f39406dd80 chore: bump version to 3.0.5
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:25:35 +08:00
zyycnandCodex 9de8e75238 fix(ui): refine account and usage empty states
Leave absent account timestamps blank, show zero latency for an empty usage summary, and reduce the expanded sidebar width by five pixels.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:24:25 +08:00
zyycnandCodex fee34b6bd1 feat(xai): expose CLI release check status
Record successful and failed official npm checks, retain the last successful version on failure, and publish the checked timestamp through the dashboard wire profile.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:24:04 +08:00
zyycnandCodex cbe8699322 perf(ui): optimize table scrollbar updates
Coalesce native scroll events per animation frame, cache scrollbar geometry, and synchronize sticky headers only when horizontal position changes.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:23:41 +08:00
zyycnandCodex 066e5551f8 perf(xai): optimize account-isolated upstream connections
Keep per-account HTTP/2 pools warm, build cold clients outside the shared cache lock, and record cache, DNS, and response-header timings for successful and failed requests.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 02:22:03 +08:00
zyycnandCodex 1d73b736a9 fix(update): restore complete streaming progress
Restore the pre-refactor update stage events and human-readable download progress, preserve progressPercent through the admin SSE wire, and wait for the stream subscription before starting replacement.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 00:17:15 +08:00
zyycnandCodex 8ae6635b9c chore: bump version to 3.0.4
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 23:40:22 +08:00
zyycnandCodex 574f0d580e fix(xai): restore Codex compatibility and runtime semantics
Preserve transient credential failures as runtime cooldowns, restore account-aware retry and observability facts, and align Grok model/tool adaptation with Codex clients.

Keep complete SSE events on the live path while retaining only protocol-required tool and continuation state.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 23:40:15 +08:00
zyycnandCodex 0879515ee8 chore: bump version to 3.0.3
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:59:19 +08:00
zyycnandCodex be40783454 fix(ui): restore dashboard and mobile interactions
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:58:08 +08:00
zyycnandCodex 946b9d005d chore: bump version to 3.0.2
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:58:47 +08:00
zyycn f181d54ac3 feat(accounts): support mixed CPR batch import
按导出文档中的 provider 分流批量导入 OpenAI 与 xAI 账号。\n\nCo-authored-by: Codex <noreply@openai.com>
2026-07-25 03:56:57 +08:00
zyycnandCodex d1a875c19a fix(update): allow GitHub release asset redirects
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:43:19 +08:00
zyycnandCodex b9335ea89f chore: bump version to 3.0.1
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:38 +08:00
zyycnandCodex 90ce543295 docs: refresh v3 provider and deployment guides
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:18 +08:00
zyycnandCodex aa3e2ddc8f fix(openai): align context and catalog tolerance
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:09 +08:00
zyycnandCodex ef57b88066 fix(openai): preserve Agent Identity CPR round trips
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:00 +08:00
94 changed files with 4169 additions and 888 deletions
+42 -24
View File
@@ -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/architecture.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
</div>
@@ -21,14 +21,15 @@
## 能力
| 领域 | 实现 |
| -------- | ------------------------------------------------------------ |
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
| Provider | OpenAI OAuth、xAI OAuth,多账号隔离与自动刷新 |
| 路由 | Client Key 平台绑定、Provider instance、模型映射与安全 fallback |
| 延续 | OpenAI native/replay continuation;xAI 客户端完整历史 |
| 管理 | Client Key、账号、模型目录、设置、观测、系统与 OAuth |
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
| 领域 | 实现 |
| -------- | --------------------------------------------------------------------------- |
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
| 透明边界 | OpenAI 保持 Responses 语义透明;xAI 的协议适配只驻留在 `providers/xai` |
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
## 快速开始
@@ -47,15 +48,14 @@ sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
```
分别生成三个密码:
分别生成 PostgreSQL 与 Redis 密码:
```bash
openssl rand -hex 24
openssl rand -hex 24
openssl rand -hex 24
```
把结果写入 `deploy/config.yaml`:
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
| 配置 | 约束 |
| -------------------------- | ---------------------------- |
@@ -64,7 +64,7 @@ openssl rand -hex 24
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
导出环境变量。
导出环境变量。管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
@@ -88,9 +88,10 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 初始化
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 创建 Provider instance,并通过 OAuth 或 JSON 导入 OpenAI、xAI 账号。
3. 按平台配置客户端模型到上游模型的映射;未配置的模型名原样透传。
4. 创建 `sk_...` 客户端 Key,绑定平台并设置模型范围、速率与并发限制。
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
和兼容账号文件导入。
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置模型范围、速率与并发限制。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
@@ -125,18 +126,22 @@ curl http://127.0.0.1:8080/v1/responses \
<details>
<summary>API 路由</summary>
| 路由 | 用途 |
| --------------------------- | ------------------- |
| `POST /v1/responses` | JSON 或 SSE 透明代理 |
| `GET /v1/responses` | WebSocket 透明代理 |
| `POST /v1/responses/review` | OpenAI review 请求 |
| `GET /v1/models` | 启用的公开模型列表 |
| `GET /v1/models/{model_id}` | 公开模型详情 |
| 路由 | 用途 |
| ---------------------------------- | ------------------------------ |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
所有 `/v1/*` 路由都需要客户端 API Key。
</details>
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
## 运维
```bash
@@ -153,14 +158,27 @@ docker compose -f deploy/compose.yaml up -d
```
> [!IMPORTANT]
> `.runtime/` 保存 PostgreSQL、Redis、自更新状态和日志。删除该目录会永久清除运行状态。
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和自更新状态。删除该目录会永久清除
> 运行状态。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
## 维护与发布
发布仓库版本必须从干净、已同步上游的发布分支运行:
```bash
release/publish <version>
```
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
不要手工修改版本后单独打 tag。
## 文档
- [部署](deploy/README.md)
- [客户端与管理端接口](docs/api.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)
+1
View File
@@ -1152,6 +1152,7 @@ dependencies = [
"rand_core 0.6.4",
"secrecy",
"serde",
"serde_json",
"subtle",
"thiserror",
"tokio",
+1
View File
@@ -27,6 +27,7 @@ thiserror = "2.0.18"
uuid = { version = "1.23.3", features = ["v7"] }
[dev-dependencies]
serde_json = { version = "1.0.150", features = ["preserve_order"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
[lints]
@@ -29,7 +29,6 @@ pub enum AccountStatus {
QuotaExhausted,
Disabled,
Banned,
Attention,
}
/// 账号列表排序字段。
@@ -184,7 +183,7 @@ pub struct AccountSummary {
pub total: u64,
pub active: u64,
pub quota_exhausted: u64,
pub attention: u64,
pub unavailable: u64,
}
/// 账号启停写入命令。
@@ -222,6 +221,8 @@ pub enum AccountConnectionTestEvent {
},
Failed {
message: String,
provider_error_code: Option<String>,
provider_error_type: Option<String>,
account_status: AccountStatus,
},
}
@@ -718,7 +718,8 @@ pub struct OpsError {
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub failure_kind: String,
pub status_code: Option<u16>,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub client_response_id: Option<String>,
pub upstream_request_id: Option<String>,
@@ -10,7 +10,6 @@ use gateway_core::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeRequest},
},
error::GatewayErrorKind,
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
};
@@ -454,12 +453,9 @@ impl AccountsService for DefaultAccountsService {
}))
.collect(),
Err(error) => vec![AccountConnectionTestEvent::Failed {
message: match error.kind() {
GatewayErrorKind::InvalidRequest
| GatewayErrorKind::Unsupported
| GatewayErrorKind::ModelNotFound => error.safe_message().to_owned(),
_ => "Provider connection test failed".to_owned(),
},
message: error.client_message().to_owned(),
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
account_status: status,
}],
}
@@ -485,8 +481,7 @@ fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> Accoun
{
AccountStatus::Expired
}
crate::model::accounts::AccountAvailability::Ready => AccountStatus::Active,
_ => AccountStatus::Attention,
_ => AccountStatus::Active,
}
}
}
@@ -2,13 +2,14 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use futures::future::BoxFuture;
use futures::{StreamExt as _, future::BoxFuture};
use gateway_core::{
engine::{
credential::{OpaqueProviderData, ProviderAccountId},
probe::{AccountProbe, AccountProbeRequest, AccountProbeResult},
},
error::GatewayError,
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
operation::{GenerateRequest, Operation, ProtocolPayload},
routing::ProviderKind,
};
@@ -17,9 +18,9 @@ use gateway_admin::{
model::{
MutationContext, Revision,
accounts::{
AccountAvailability, AccountListQuery, AccountPage, AccountRecord, AccountSummary,
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, DeleteAccounts,
SetAccountEnabled,
AccountAvailability, AccountConnectionTestEvent, AccountListQuery, AccountPage,
AccountRecord, AccountSummary, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
},
observability::TimeRange,
provider_credentials::{
@@ -48,6 +49,7 @@ use gateway_admin::{
},
},
};
use serde_json::{Map, json};
pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
@@ -159,10 +161,22 @@ impl ProviderAdmin for FakeProviderAdmin {
fn connection_test_operation(
&self,
_: &gateway_core::routing::UpstreamModelId,
_: &str,
model: &gateway_core::routing::UpstreamModelId,
input: &str,
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!(model.as_str())),
("input".to_owned(), json!(input)),
("stream".to_owned(), json!(true)),
("store".to_owned(), json!(false)),
]),
)
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid))?;
Ok(Operation::Generate(GenerateRequest::from_protocol_payload(
payload,
)))
}
fn dashboard_wire_profile(
@@ -395,7 +409,7 @@ impl AccountStore for FakeAccountStore {
total: 1,
active: 1,
quota_exhausted: 0,
attention: 0,
unavailable: 0,
},
})
}
@@ -686,6 +700,37 @@ fn provider_registry_should_reject_duplicate_kind() {
));
}
#[tokio::test]
async fn connection_test_should_preserve_safe_provider_error_details() {
let provider = FakeProviderAdmin::new("xai", events());
let store = FakeAccountStore::new("xai", events());
let services =
accounts_service_with_probe(provider, store, Arc::new(FailingAccountProbe)).await;
let events = services
.accounts()
.test_connection(
ProviderAccountId::new("acct_test").expect("account ID"),
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
)
.await
.expect("connection test stream")
.collect::<Vec<_>>()
.await;
assert!(matches!(
events.last(),
Some(AccountConnectionTestEvent::Failed {
message,
provider_error_code: Some(code),
provider_error_type: Some(error_type),
..
}) if message == "included usage exhausted"
&& code == "usage_exhausted"
&& error_type == "invalid_request_error"
));
}
#[tokio::test]
async fn accounts_export_should_pass_store_loaded_timestamps_and_material_to_provider() {
let provider = FakeProviderAdmin::new("openai", events());
@@ -1005,12 +1050,20 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
async fn accounts_service(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
) -> AdminServices {
accounts_service_with_probe(provider, store, Arc::new(SuccessfulAccountProbe)).await
}
async fn accounts_service_with_probe(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
probe: Arc<dyn AccountProbe>,
) -> AdminServices {
super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore))
.provider(provider)
.probe(Arc::new(SuccessfulAccountProbe))
.probe(probe)
.build()
.await
}
@@ -1030,6 +1083,29 @@ impl AccountProbe for SuccessfulAccountProbe {
}
}
struct FailingAccountProbe;
impl AccountProbe for FailingAccountProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> BoxFuture<'_, Result<AccountProbeResult, GatewayError>> {
Box::pin(async {
let detail = ClientVisibleUpstreamError::new(
"included usage exhausted",
Some("usage_exhausted".to_owned()),
Some("invalid_request_error".to_owned()),
)
.expect("safe provider error");
Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"upstream capacity is temporarily unavailable",
)
.with_client_visible_upstream_error(detail))
})
}
}
fn store_unavailable() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
@@ -174,7 +174,7 @@ pub struct AccountSummaryView {
pub total: u64,
pub active: u64,
pub quota_exhausted: u64,
pub attention: u64,
pub unavailable: u64,
}
/// 一条安全账号视图。
@@ -543,10 +543,14 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
}),
DomainConnectionTestEvent::Failed {
message,
provider_error_code,
provider_error_type,
account_status,
} => serde_json::json!({
"type": "error",
"error": message,
"providerErrorCode": provider_error_code,
"providerErrorType": provider_error_type,
"accountStatus": account_status_name(account_status)
}),
};
@@ -1401,7 +1405,7 @@ fn account_page_data(
total: result.summary.total,
active: result.summary.active,
quota_exhausted: result.summary.quota_exhausted,
attention: result.summary.attention,
unavailable: result.summary.unavailable,
},
}
}
@@ -1798,7 +1802,6 @@ fn account_status_name(status: DomainAccountStatus) -> &'static str {
DomainAccountStatus::QuotaExhausted => "quota_exhausted",
DomainAccountStatus::Disabled => "disabled",
DomainAccountStatus::Banned => "banned",
DomainAccountStatus::Attention => "attention",
}
}
@@ -801,8 +801,8 @@ pub struct DashboardCardsView {
pub struct DashboardCredentialsCardView {
pub total: String,
pub total_value: u64,
pub enabled: String,
pub enabled_value: u64,
pub available: String,
pub available_value: u64,
pub unavailable: String,
pub unavailable_value: u64,
}
@@ -1235,7 +1235,6 @@ pub struct OpsErrorView {
pub account_id: Option<String>,
pub route: String,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
@@ -1954,7 +1953,6 @@ fn usage_page_view(
}
fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
let status = error.status_code.map(i64::from);
OpsErrorView {
id: error.event_id,
request_id: error.request_id,
@@ -1964,9 +1962,8 @@ fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
account_id: error.provider_account_ref,
route: error.operation,
model: error.upstream_model_id,
status_code: status,
client_status_code: None,
upstream_status_code: None,
client_status_code: error.client_status_code.map(i64::from),
upstream_status_code: error.upstream_status_code.map(i64::from),
transport: error.upstream_transport,
attempt_index: error.attempt_index,
failure_class: error.failure_kind,
@@ -2290,8 +2287,8 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total_value: provider_accounts.total,
enabled: format_compact_number(provider_accounts.enabled),
enabled_value: provider_accounts.enabled,
available: format_compact_number(provider_accounts.active),
available_value: provider_accounts.active,
unavailable: format_compact_number(provider_accounts.unavailable),
unavailable_value: provider_accounts.unavailable,
},
@@ -201,11 +201,13 @@ struct RestartAcceptedView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateEventView {
id: String,
operation_id: Option<String>,
level: &'static str,
step: Option<String>,
message: String,
terminal: bool,
progress_percent: Option<u8>,
at: String,
}
@@ -403,11 +405,13 @@ where
impl From<SystemUpdateEvent> for SystemUpdateEventView {
fn from(event: SystemUpdateEvent) -> Self {
Self {
id: event.id,
operation_id: event.operation_id,
level: update_event_level_name(event.level),
step: event.step,
message: event.message,
terminal: event.terminal,
progress_percent: event.progress_percent,
at: event.occurred_at.to_rfc3339(),
}
}
@@ -416,11 +420,13 @@ impl From<SystemUpdateEvent> for SystemUpdateEventView {
impl SystemUpdateEventView {
fn into_json(self) -> serde_json::Value {
serde_json::json!({
"id": self.id,
"operationId": self.operation_id,
"level": self.level,
"step": self.step,
"message": self.message,
"terminal": self.terminal,
"progressPercent": self.progress_percent,
"at": self.at,
})
}
@@ -6,7 +6,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::PublicModelId;
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use serde_json::{Value, json};
use crate::ApiState;
@@ -17,6 +17,7 @@ use super::{
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result.";
/// `GET /v1/models`。
pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) -> Response {
@@ -30,6 +31,16 @@ pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) ->
.into_iter()
.map(|model| openai_model_json(&model))
.collect::<Vec<_>>();
let profiles = service.public_model_profiles(&client);
if !profiles.is_empty() {
let models = profiles
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
return (StatusCode::OK, Json(json!({ "models": models }))).into_response();
}
(
StatusCode::OK,
@@ -134,3 +145,75 @@ fn catalog_model_json(id: &str) -> Value {
"source": "gateway",
})
}
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
let id = profile.model().as_str();
let presentation = profile.presentation();
let reasoning_levels = presentation
.supported_reasoning_efforts()
.iter()
.map(|effort| {
json!({
"effort": effort,
"description": reasoning_effort_description(effort),
})
})
.collect::<Vec<_>>();
let reasoning_supported = presentation
.supported_reasoning_efforts()
.iter()
.any(|effort| effort != "none");
let context_window = presentation.context_window_tokens();
let input_modalities = if presentation.image_input() {
vec!["text", "image"]
} else {
vec!["text"]
};
json!({
"slug": id,
"display_name": presentation.display_name().unwrap_or(id),
"description": presentation.description(),
"default_reasoning_level": presentation.default_reasoning_effort(),
"supported_reasoning_levels": reasoning_levels,
"shell_type": "shell_command",
"visibility": if presentation.hidden() { "hide" } else { "list" },
"supported_in_api": true,
"priority": index.saturating_add(1),
"additional_speed_tiers": [],
"service_tiers": [],
"availability_nux": Value::Null,
"upgrade": Value::Null,
"base_instructions": CODEX_BASE_INSTRUCTIONS,
"model_messages": Value::Null,
"include_skills_usage_instructions": false,
"supports_reasoning_summary_parameter": reasoning_supported,
"default_reasoning_summary": "auto",
"support_verbosity": presentation.verbosity(),
"default_verbosity": Value::Null,
"apply_patch_tool_type": presentation.agent_tools().then_some("freeform"),
"web_search_tool_type": "text",
"truncation_policy": { "mode": "tokens", "limit": 10_000 },
"supports_parallel_tool_calls": presentation.parallel_tool_calls(),
"supports_image_detail_original": presentation.image_detail_original(),
"context_window": context_window,
"max_context_window": context_window,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": input_modalities,
"supports_search_tool": presentation.search_tool(),
"use_responses_lite": false,
})
}
fn reasoning_effort_description(effort: &str) -> &'static str {
match effort {
"none" => "No reasoning",
"low" => "Fast responses with lighter reasoning",
"medium" => "Balances speed and reasoning depth for everyday tasks",
"high" => "Greater reasoning depth for complex problems",
"xhigh" => "Extra high reasoning depth for complex problems",
"max" => "Maximum reasoning depth for the hardest problems",
_ => "Provider-supported reasoning level",
}
}
@@ -9,7 +9,7 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::PublicModelId;
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
@@ -51,6 +51,13 @@ impl OpenAiService {
.collect()
}
pub(crate) fn public_model_profiles(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
}
pub(crate) fn contains_public_model(
&self,
client: &AuthenticatedClient,
@@ -276,6 +276,8 @@ mod actions {
},
DomainConnectionTestEvent::Failed {
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
provider_error_type: Some("invalid_request_error".to_owned()),
account_status: AccountStatus::Active,
},
]
@@ -302,7 +304,13 @@ mod actions {
}),
json!({ "type": "content", "text": "OK" }),
json!({ "type": "test_complete", "success": true, "accountStatus": "active" }),
json!({ "type": "error", "error": "upstream unavailable", "accountStatus": "active" }),
json!({
"type": "error",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
"accountStatus": "active"
}),
]
);
}
@@ -84,6 +84,10 @@ pub(super) struct AdminTestFixture {
impl AdminTestFixture {
pub async fn new() -> Self {
Self::with_system(Arc::new(UnusedSystem)).await
}
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
let api_key = Arc::new(Mutex::new(None));
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
let settings = Arc::new(MemorySettingsStore::new(api_key));
@@ -113,7 +117,7 @@ impl AdminTestFixture {
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
Arc::new(UnusedSystem),
system,
)
.await
.expect("initialize test admin services");
@@ -1,4 +1,24 @@
use gateway_api::admin::system::{UpdateDetailQuery, UpdateRequest};
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, header},
};
use chrono::Utc;
use futures::stream;
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdateStatus, SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
use gateway_api::admin::system::{self, UpdateDetailQuery, UpdateRequest};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState, unavailable_system};
#[test]
fn update_detail_query_should_default_to_no_refresh_and_reject_unknown_fields() {
@@ -16,3 +36,84 @@ fn update_request_should_preserve_target_version_for_domain_validation() {
serde_json::from_str::<UpdateRequest>(r#"{"targetVersion":"v0.2.0","extra":1}"#).is_err()
);
}
#[tokio::test]
async fn update_event_stream_should_preserve_event_id_and_download_progress_percent() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/system/update/events")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_system_update_events")
.body(Body::empty())
.expect("update event request"),
)
.await
.expect("update event response");
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("update event body");
let body = String::from_utf8(body.to_vec()).expect("UTF-8 event stream");
assert!(
body.contains(r#""id":"update-progress-1""#),
"unexpected update event stream: {body}"
);
assert!(
body.contains(r#""progressPercent":40"#),
"unexpected update event stream: {body}"
);
}
fn app(state: AdminTestState) -> Router {
system::router::<AdminTestState>().with_state(state)
}
struct ProgressSystem;
#[async_trait]
impl SystemOperations for ProgressSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(stream::once(async {
SystemUpdateEvent {
id: "update-progress-1".to_owned(),
operation_id: Some("update-1".to_owned()),
level: SystemUpdateEventLevel::Info,
step: Some("download".to_owned()),
message: "已下载 4.0 MiB / 10.0 MiB (40%)".to_owned(),
terminal: true,
progress_percent: Some(40),
occurred_at: Utc::now(),
}
}))
}
async fn perform_update(
&self,
_: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -10,19 +10,28 @@ use gateway_core::engine::execution::{
StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::PublicModelId;
use gateway_core::routing::{ModelPresentation, PublicModelId, PublicModelProfile};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
struct ModelsExecution {
client: AuthenticatedClient,
profiles: bool,
}
impl ModelsExecution {
fn new() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: false,
})
}
fn with_profiles() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: true,
})
}
}
@@ -46,6 +55,26 @@ impl ExecutionService for ModelsExecution {
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
if !self.profiles {
return Vec::new();
}
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
ModelPresentation::new(
Some("Grok 4.5".to_owned()),
Some("xAI Grok 4.5 frontier model.".to_owned()),
)
.with_reasoning(
Some("medium".to_owned()),
["low", "medium", "high"].map(str::to_owned).to_vec(),
)
.with_context_window_tokens(Some(500_000))
.with_image_input(true)
.with_agent_tools(true, true),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
@@ -91,6 +120,26 @@ async fn models_should_encode_the_service_visible_catalog() {
);
}
#[tokio::test]
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
let response = api_router(ModelsExecution::with_profiles())
.await
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
.await
.expect("list Codex models response");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read Codex models body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
let model = &value["models"][0];
assert_eq!(model["slug"], "grok-4.5");
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(model["context_window"], 500_000);
assert_eq!(model["apply_patch_tool_type"], "freeform");
assert_eq!(model["service_tiers"], serde_json::json!([]));
}
#[tokio::test]
async fn model_detail_should_keep_the_official_path_id_contract() {
let response = api_router(ModelsExecution::new())
@@ -1,6 +1,6 @@
//! 唯一的账号重试、发送与下游 commit barrier owner。
use std::collections::{BTreeSet, VecDeque};
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
@@ -67,12 +67,6 @@ where
let continuation_attempt =
initial_continuation_attempt(&operation, &plan, continuation.as_ref());
let image_generation_requested = operation.image_generation_requested();
let commit_policy = if continuation_attempt == ContinuationAttempt::None {
StreamCommitPolicy::FirstCanonicalEvent
} else {
StreamCommitPolicy::UntilOutputOrTerminal
};
let mut session = ResponseExecutionSession {
engine: Arc::clone(&self.engine),
request_id,
@@ -86,7 +80,6 @@ where
required_account,
continuation,
continuation_attempt,
commit_policy,
account_state_owner,
cancellation,
attempts: 0,
@@ -106,7 +99,6 @@ where
upstream_response_id: None,
last_account_exhaustion: None,
provider_attempt_outcomes: Vec::new(),
before_commit: VecDeque::new(),
};
if session.cancellation.is_cancelled() {
@@ -141,12 +133,6 @@ struct FailureFinalization {
provider_response_id: Option<String>,
}
#[derive(Clone, Copy)]
enum StreamCommitPolicy {
FirstCanonicalEvent,
UntilOutputOrTerminal,
}
/// API 可逐事件消费的 Core 执行会话。
///
/// API 只能提交下游 delivery 边界;账号重试、断流终结与
@@ -164,7 +150,6 @@ pub struct ResponseExecutionSession<S: ?Sized> {
required_account: Option<crate::engine::credential::ProviderAccountId>,
continuation: Option<ContinuationBinding>,
continuation_attempt: ContinuationAttempt,
commit_policy: StreamCommitPolicy,
account_state_owner: Option<ProviderAccountStateOwner>,
cancellation: CancellationToken,
attempts: u32,
@@ -184,7 +169,6 @@ pub struct ResponseExecutionSession<S: ?Sized> {
upstream_response_id: Option<String>,
last_account_exhaustion: Option<ProviderError>,
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
before_commit: VecDeque<ProviderEvent>,
}
impl<S: ?Sized> ResponseExecutionSession<S>
@@ -203,15 +187,6 @@ where
if self.finalized {
return Ok(None);
}
if self.downstream_committed_at.is_some()
&& let Some(event) = self.before_commit.pop_front()
{
return Ok(Some(CoordinatedEvent::single(
event,
CommitRequirement::AlreadyCommitted,
)));
}
loop {
match self.pull().await? {
PullOutcome::Event(event) => {
@@ -221,25 +196,13 @@ where
CommitRequirement::AlreadyCommitted,
)));
}
let commit_significant = match self.commit_policy {
StreamCommitPolicy::FirstCanonicalEvent => event.has_canonical_facts(),
StreamCommitPolicy::UntilOutputOrTerminal => event.is_commit_significant(),
};
self.before_commit.push_back(event);
if !commit_significant {
continue;
}
self.delivery_pending = true;
let events = self.before_commit.drain(..).collect();
return CoordinatedEvent::try_batch(
events,
return Ok(Some(CoordinatedEvent::single(
event,
CommitRequirement::CommitBeforeDelivery,
)
.map(Some);
}
PullOutcome::AttemptDiscarded => {
self.before_commit.clear();
)));
}
PullOutcome::AttemptDiscarded => {}
PullOutcome::End => {
if self.downstream_committed_at.is_some() {
self.finish_success().await?;
@@ -953,7 +916,6 @@ where
self.timings.first_text_ms = None;
self.timings.first_token_ms = None;
self.upstream_complete = false;
self.before_commit.clear();
}
async fn finish_success(&mut self) -> Result<(), EngineError> {
@@ -29,7 +29,9 @@ use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
use crate::operation::{Operation, ProviderSessionState};
use crate::policy::{ClientApiKeyId, ClientPolicy};
use crate::routing::snapshot::RuntimeSnapshotHandle;
use crate::routing::{ProviderKind, PublicModelId, RoutingContext, RuntimeSnapshot};
use crate::routing::{
ProviderKind, PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot,
};
const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60);
const CONTINUATION_AFFINITY_TIMEOUT: Duration = Duration::from_millis(100);
@@ -139,6 +141,9 @@ pub trait ExecutionService: Send + Sync {
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError>;
fn public_models(&self, client: &AuthenticatedClient) -> Vec<PublicModelId>;
fn public_model_profiles(&self, _client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
Vec::new()
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool;
fn start(
&self,
@@ -568,6 +573,12 @@ impl ExecutionService for DefaultExecutionService {
.public_models_for_provider(client.policy.provider_kind())
}
fn public_model_profiles(&self, client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
client
.snapshot
.public_model_profiles_for_provider(client.policy.provider_kind())
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool {
client
.snapshot
@@ -20,7 +20,9 @@ use crate::error::{
};
use crate::event::{EventSequenceValidator, ProviderEvent};
use crate::operation::Operation;
use crate::routing::{ModelCapabilities, ProviderCandidate, ProviderKind, UpstreamModelId};
use crate::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
};
/// Box 只出现在 Provider Registry 的统一 event envelope 边界。
pub type EventStream =
@@ -433,6 +435,7 @@ pub struct ProviderRequestObservation {
pub struct ProviderModelCapabilities {
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
presentation: Option<ModelPresentation>,
}
/// Provider 实时目录成功发布后的进程内单调代次。
@@ -459,9 +462,16 @@ impl ProviderModelCapabilities {
Self {
upstream_model,
capabilities,
presentation: None,
}
}
#[must_use]
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
self.presentation = Some(presentation);
self
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
@@ -471,6 +481,11 @@ impl ProviderModelCapabilities {
pub const fn capabilities(&self) -> &ModelCapabilities {
&self.capabilities
}
#[must_use]
pub const fn presentation(&self) -> Option<&ModelPresentation> {
self.presentation.as_ref()
}
}
impl ProviderRequest {
-29
View File
@@ -756,18 +756,6 @@ impl ProviderEvent {
pub fn has_client_event(&self) -> bool {
!self.canonical.is_empty() || self.wire.is_some()
}
/// 返回是否包含足以冻结下游 commit barrier 的 canonical fact。
///
/// 单独的 wire event 不能冻结边界:协议层在看到 `Started`
/// 携带的网关响应身份前无法安全编码它。Core 会保留这些
/// 事件,直到首个 canonical 可见增量或终态再一并交付。
#[must_use]
pub fn is_commit_significant(&self) -> bool {
self.canonical
.iter()
.any(GatewayEvent::is_commit_significant)
}
}
impl From<GatewayEvent> for ProviderEvent {
@@ -776,23 +764,6 @@ impl From<GatewayEvent> for ProviderEvent {
}
}
impl GatewayEvent {
/// 返回该 fact 是否足以冻结下游 commit barrier。
///
/// 生命周期、结构与结算 facts 可以在换号前安全丢弃;首个可见增量或
/// Provider 正常终态才会使客户端输出不可撤回。
#[must_use]
pub const fn is_commit_significant(&self) -> bool {
matches!(
self,
Self::TextDelta(_)
| Self::ReasoningDelta(_)
| Self::ToolCallDelta(_)
| Self::Completed(_)
)
}
}
/// Canonical event 顺序错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EventSequenceError {
@@ -476,7 +476,7 @@ pub trait ProviderCredentialStatePort: Send + Sync {
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
}
/// 临时 cooldown 只保存调度截止时间;原因事实仍由 PostgreSQL 账号状态持有。
/// 临时 cooldown 只保存可丢失的调度截止时间,不进入账号持久状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCooldown {
account_id: ProviderAccountId,
@@ -136,6 +136,177 @@ pub struct ModelCapabilities {
upstream_validates_features: bool,
}
/// Provider 为客户端模型目录提供的展示与交互能力。
///
/// 该值不参与路由,也不把任一 Provider 的 wire 类型带入 Core。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelPresentation {
display_name: Option<String>,
description: Option<String>,
default_reasoning_effort: Option<String>,
supported_reasoning_efforts: Vec<String>,
context_window_tokens: Option<u64>,
image_input: bool,
agent_tools: bool,
parallel_tool_calls: bool,
search_tool: bool,
image_detail_original: bool,
verbosity: bool,
hidden: bool,
}
impl ModelPresentation {
#[must_use]
pub fn new(display_name: Option<String>, description: Option<String>) -> Self {
Self {
display_name,
description,
..Self::default()
}
}
#[must_use]
pub fn with_reasoning(
mut self,
default_effort: Option<String>,
supported_efforts: Vec<String>,
) -> Self {
self.default_reasoning_effort = default_effort;
self.supported_reasoning_efforts = supported_efforts;
self
}
#[must_use]
pub const fn with_context_window_tokens(mut self, context_window_tokens: Option<u64>) -> Self {
self.context_window_tokens = context_window_tokens;
self
}
#[must_use]
pub const fn with_image_input(mut self, image_input: bool) -> Self {
self.image_input = image_input;
self
}
#[must_use]
pub const fn with_agent_tools(mut self, agent_tools: bool, parallel_tool_calls: bool) -> Self {
self.agent_tools = agent_tools;
self.parallel_tool_calls = parallel_tool_calls;
self
}
#[must_use]
pub const fn with_search_tool(mut self, search_tool: bool) -> Self {
self.search_tool = search_tool;
self
}
#[must_use]
pub const fn with_image_detail_original(mut self, image_detail_original: bool) -> Self {
self.image_detail_original = image_detail_original;
self
}
#[must_use]
pub const fn with_verbosity(mut self, verbosity: bool) -> Self {
self.verbosity = verbosity;
self
}
#[must_use]
pub const fn with_hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
#[must_use]
pub fn display_name(&self) -> Option<&str> {
self.display_name.as_deref()
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub fn default_reasoning_effort(&self) -> Option<&str> {
self.default_reasoning_effort.as_deref()
}
#[must_use]
pub fn supported_reasoning_efforts(&self) -> &[String] {
&self.supported_reasoning_efforts
}
#[must_use]
pub const fn context_window_tokens(&self) -> Option<u64> {
self.context_window_tokens
}
#[must_use]
pub const fn image_input(&self) -> bool {
self.image_input
}
#[must_use]
pub const fn agent_tools(&self) -> bool {
self.agent_tools
}
#[must_use]
pub const fn parallel_tool_calls(&self) -> bool {
self.parallel_tool_calls
}
#[must_use]
pub const fn search_tool(&self) -> bool {
self.search_tool
}
#[must_use]
pub const fn image_detail_original(&self) -> bool {
self.image_detail_original
}
#[must_use]
pub const fn verbosity(&self) -> bool {
self.verbosity
}
#[must_use]
pub const fn hidden(&self) -> bool {
self.hidden
}
}
/// 一个公开模型及其 Provider 编译后的客户端画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicModelProfile {
model: PublicModelId,
presentation: ModelPresentation,
}
impl PublicModelProfile {
#[must_use]
pub const fn new(model: PublicModelId, presentation: ModelPresentation) -> Self {
Self {
model,
presentation,
}
}
#[must_use]
pub const fn model(&self) -> &PublicModelId {
&self.model
}
#[must_use]
pub const fn presentation(&self) -> &ModelPresentation {
&self.presentation
}
}
impl ModelCapabilities {
#[must_use]
pub fn new(operations: BTreeSet<OperationKind>, max_output_tokens: Option<u64>) -> Self {
@@ -205,6 +376,7 @@ pub struct ProviderModel {
provider: ProviderKind,
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
presentation: Option<ModelPresentation>,
}
impl ProviderModel {
@@ -218,9 +390,16 @@ impl ProviderModel {
provider,
upstream_model,
capabilities,
presentation: None,
}
}
#[must_use]
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
self.presentation = Some(presentation);
self
}
#[must_use]
pub const fn provider(&self) -> &ProviderKind {
&self.provider
@@ -230,6 +409,11 @@ impl ProviderModel {
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
}
#[must_use]
pub const fn presentation(&self) -> Option<&ModelPresentation> {
self.presentation.as_ref()
}
}
/// 本次请求选择 Provider 时使用的动态过滤事实。
@@ -231,11 +231,15 @@ async fn compile_runtime_snapshot(
continue;
};
provider_models.extend(models.into_iter().map(|model| {
ProviderModel::new(
let compiled = ProviderModel::new(
provider.clone(),
model.upstream_model().clone(),
model.capabilities().clone(),
)
);
match model.presentation().cloned() {
Some(presentation) => compiled.with_presentation(presentation),
None => compiled,
}
}));
}
@@ -287,6 +291,8 @@ pub struct RuntimeSnapshot {
account_selection_policy: AccountSelectionPolicy,
providers: Arc<BTreeSet<ProviderKind>>,
provider_models: Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>>,
provider_model_presentations:
Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>>,
model_mappings: Arc<BTreeMap<String, String>>,
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
@@ -313,17 +319,24 @@ impl RuntimeSnapshot {
let mut model_map =
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>::new();
let mut presentation_map =
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>::new();
for model in provider_models {
if !provider_set.contains(model.provider()) {
let ProviderModel {
provider,
upstream_model,
capabilities,
presentation,
} = model;
if !provider_set.contains(&provider) {
return Err(RoutingError::NotFound {
entity: "provider",
id: model.provider().to_string(),
id: provider.to_string(),
});
}
let models = model_map.entry(model.provider).or_default();
let upstream_model = model.upstream_model;
let models = model_map.entry(provider.clone()).or_default();
if models
.insert(upstream_model.clone(), model.capabilities)
.insert(upstream_model.clone(), capabilities)
.is_some()
{
return Err(RoutingError::DuplicateEntity {
@@ -331,6 +344,12 @@ impl RuntimeSnapshot {
id: upstream_model.to_string(),
});
}
if let Some(presentation) = presentation {
presentation_map
.entry(provider)
.or_default()
.insert(upstream_model, presentation);
}
}
let mut client_policy_map = BTreeMap::new();
@@ -350,6 +369,7 @@ impl RuntimeSnapshot {
account_selection_policy,
providers: Arc::new(provider_set),
provider_models: Arc::new(model_map),
provider_model_presentations: Arc::new(presentation_map),
model_mappings: Arc::new(BTreeMap::new()),
provider_catalog_generations: Arc::new(BTreeMap::new()),
client_policies: Arc::new(client_policy_map),
@@ -402,6 +422,38 @@ impl RuntimeSnapshot {
models.into_iter().collect()
}
/// 返回 Provider 已明确声明画像的公开模型;没有画像时不猜测 Provider 语义。
#[must_use]
pub fn public_model_profiles_for_provider(
&self,
provider: &ProviderKind,
) -> Vec<super::PublicModelProfile> {
let Some(presentations) = self.provider_model_presentations.get(provider) else {
return Vec::new();
};
let mut profiles = BTreeMap::new();
for (model, presentation) in presentations {
if let Ok(public_model) = PublicModelId::new(model.as_str().to_owned()) {
profiles.insert(public_model, presentation.clone());
}
}
for alias in self.model_mappings.keys() {
let target = self.mapped_model(alias);
let Some(presentation) = presentations.iter().find_map(|(model, presentation)| {
(model.as_str() == target).then_some(presentation)
}) else {
continue;
};
if let Ok(public_model) = PublicModelId::new(alias.clone()) {
profiles.insert(public_model, presentation.clone());
}
}
profiles
.into_iter()
.map(|(model, presentation)| super::PublicModelProfile::new(model, presentation))
.collect()
}
#[must_use]
pub fn public_models(&self) -> Vec<PublicModelId> {
self.providers
@@ -1005,6 +1005,51 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
));
}
#[test]
fn streaming_native_continuation_delivers_started_event_before_later_output() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, _, _) = coordinator(vec![Script::Stream {
account_id: "acct_one",
items: complete_stream(None),
}]);
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-response").expect("previous response"),
SafeUpstreamValue::new("upstream-response").expect("upstream response"),
ProviderKind::new("openai").expect("provider"),
ProviderAccountId::new("acct_one").expect("account"),
);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
let first = block_on(session.next_event())
.expect("first event")
.expect("started event");
assert_eq!(
first.commit_requirement(),
CommitRequirement::CommitBeforeDelivery
);
let events = first.into_provider_events();
assert!(matches!(
events.as_slice(),
[event]
if matches!(event.canonical_facts(), [GatewayEvent::Started(_)])
));
block_on(session.commit_downstream(Some(200))).expect("commit started event");
while block_on(session.next_event())
.expect("remaining event")
.is_some()
{}
}
#[test]
fn native_continuation_replays_owner_before_safely_switching_account() {
let Operation::Generate(generate) = generate_operation() else {
@@ -24,8 +24,8 @@ use gateway_core::routing::snapshot::{
SnapshotSubscriptionError, SnapshotSubscriptionPort, runtime_revision_needs_refresh,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderKind, PublicModelId, RuntimeSnapshot,
UpstreamModelId,
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderKind, PublicModelId,
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::WorkerKind;
@@ -135,13 +135,19 @@ impl Provider for PublishingCatalogProvider {
if self.queries.fetch_add(1, Ordering::SeqCst) == 0 {
self.generation.store(1, Ordering::SeqCst);
}
Ok(vec![ProviderModelCapabilities::new(
UpstreamModelId::new("upstream-model").expect("model"),
ModelCapabilities::new(
std::collections::BTreeSet::from([OperationKind::Generate]),
Ok(vec![
ProviderModelCapabilities::new(
UpstreamModelId::new("upstream-model").expect("model"),
ModelCapabilities::new(
std::collections::BTreeSet::from([OperationKind::Generate]),
None,
),
)
.with_presentation(ModelPresentation::new(
Some("Upstream Model".to_owned()),
None,
),
)])
)),
])
}
async fn execute(
@@ -215,6 +221,15 @@ fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
.map(|generation| generation.get()),
Some(1),
);
let profiles =
snapshot.public_model_profiles_for_provider(&ProviderKind::new("alpha").expect("provider"));
assert_eq!(
profiles
.iter()
.map(|profile| profile.model().as_str())
.collect::<Vec<_>>(),
vec!["public-model", "upstream-model"],
);
}
#[test]
@@ -49,7 +49,7 @@ pub(crate) async fn download_file(
.map_err(|error| super::internal(format!("failed to create download file: {error}")))?;
let mut stream = response.bytes_stream();
let mut downloaded = 0_u64;
let mut last_percent = 0_u8;
let mut next_progress = 10_u8;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| upstream(format!("download stream failed: {error}")))?;
downloaded = downloaded.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
@@ -59,9 +59,17 @@ pub(crate) async fn download_file(
file.write_all(&chunk)
.map_err(|error| super::internal(format!("failed to write download: {error}")))?;
let percent = download_percent(downloaded, expected_size);
if percent > last_percent {
last_percent = percent;
events.emit_progress(operation_id, "downloading release archive", percent);
if percent >= next_progress || downloaded == expected_size {
events.emit_progress(
operation_id,
&format!(
"已下载 {} / {} ({percent}%)",
format_bytes(downloaded),
format_bytes(expected_size)
),
percent,
);
next_progress = percent.saturating_add(10);
}
}
file.sync_all()
@@ -155,3 +163,15 @@ fn download_percent(downloaded: u64, total: u64) -> u8 {
)
.unwrap_or(100)
}
pub(crate) fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
if bytes >= MIB {
return format!("{:.1} MiB", bytes as f64 / MIB as f64);
}
if bytes >= KIB {
return format!("{:.1} KiB", bytes as f64 / KIB as f64);
}
format!("{bytes} B")
}
@@ -39,6 +39,8 @@ use self::state::{
};
use self::swap::{replace_release_files, rollback_release};
pub use self::release::validate_download_url;
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist";
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos";
@@ -230,8 +232,15 @@ impl ProcessSystemOperations {
.as_deref()
.ok_or_else(|| conflict("update repository is not configured"))?;
self.events
.info(None, Some("release"), "fetching latest release");
let release = fetch_latest(&self.config.github_api_base, repository).await?;
.info(None, Some("release"), "正在获取最新 Release 信息");
let release = match fetch_latest(&self.config.github_api_base, repository).await {
Ok(release) => release,
Err(error) => {
self.events
.error_terminal(None, Some("release"), error.to_string());
return Err(error);
}
};
let detail = detail_from_release(&self.config, &release);
if detail.latest_version != target {
self.events.warning_terminal(
@@ -256,12 +265,17 @@ impl ProcessSystemOperations {
Some(&target),
&self.config.version,
)?;
self.events.info(
Some(&operation_id),
Some("prepare"),
format!("准备更新到 v{target}"),
);
let result = self.install_release(&release, &target, &operation_id).await;
match &result {
Ok(()) => self.events.success_terminal(
Some(&operation_id),
Some("done"),
"release files replaced",
"更新文件已替换,等待服务重启生效",
),
Err(error) => {
self.events
@@ -292,7 +306,23 @@ impl ProcessSystemOperations {
version: &str,
operation_id: &str,
) -> Result<(), OperationError> {
self.events.info(
Some(operation_id),
Some("asset"),
"正在选择匹配当前平台的更新包",
);
let archive = select_archive(release, version)?;
self.events.info(
Some(operation_id),
Some("asset"),
format!(
"已选择更新包 {} ({})",
archive.name,
download::format_bytes(archive.size)
),
);
self.events
.info(Some(operation_id), Some("verify"), "正在校验更新资源");
if archive.size == 0 || archive.size > MAX_DOWNLOAD_SIZE {
return Err(invalid("release archive size is invalid"));
}
@@ -304,12 +334,16 @@ impl ProcessSystemOperations {
if checksum.size == 0 || checksum.size > MAX_CHECKSUM_SIZE {
return Err(invalid("release checksum size is invalid"));
}
self.events
.info(Some(operation_id), Some("prepare"), "正在创建临时更新目录");
fs::create_dir_all(&self.config.update_temp_dir)
.map_err(|error| internal(format!("failed to prepare update temp dir: {error}")))?;
let temp_root = fs::canonicalize(&self.config.update_temp_dir)
.map_err(|error| internal(format!("failed to resolve update temp dir: {error}")))?;
let temp = UpdateTempDir::create(&temp_root)?;
let archive_path = temp.path().join(&archive.name);
self.events
.info(Some(operation_id), Some("download"), "开始下载更新包");
download_file(
&archive.browser_download_url,
&archive_path,
@@ -319,6 +353,10 @@ impl ProcessSystemOperations {
&self.events,
)
.await?;
self.events
.success(Some(operation_id), Some("download"), "更新包下载完成");
self.events
.info(Some(operation_id), Some("checksum"), "正在校验 checksum");
verify_checksum(
&archive_path,
&archive.name,
@@ -327,12 +365,23 @@ impl ProcessSystemOperations {
&self.config.github_api_base,
)
.await?;
self.events
.success(Some(operation_id), Some("checksum"), "checksum 校验通过");
self.events
.info(Some(operation_id), Some("extract"), "正在解压更新包");
let extracted = extract_release(&archive_path, temp.path())?;
self.events
.success(Some(operation_id), Some("extract"), "更新包解压完成");
self.events
.info(Some(operation_id), Some("replace"), "正在替换应用文件");
replace_release_files(
&self.config.executable_path()?,
&self.config.web_dist_dir,
extracted,
)
)?;
self.events
.success(Some(operation_id), Some("replace"), "应用文件替换完成");
Ok(())
}
}
@@ -513,6 +562,17 @@ impl UpdateEvents {
);
}
fn success(&self, operation_id: Option<&str>, step: Option<&str>, message: impl Into<String>) {
self.emit(
SystemUpdateEventLevel::Success,
operation_id,
step,
message,
None,
false,
);
}
fn warning_terminal(
&self,
operation_id: Option<&str>,
@@ -177,7 +177,8 @@ pub(crate) fn select_archive<'a>(
})
}
pub(crate) fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
/// 校验 GitHub Release 资产 URL 及其重定向目标是否位于受信任边界内。
pub fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
let url = reqwest::Url::parse(raw)
.map_err(|error| invalid(format!("invalid download URL: {error}")))?;
if local_download_allowed(&url, api_base) {
@@ -294,6 +295,7 @@ fn github_download_host_allowed(host: &str) -> bool {
|| host.ends_with(".github.com")
|| host == "objects.githubusercontent.com"
|| host.ends_with(".objects.githubusercontent.com")
|| host == "release-assets.githubusercontent.com"
}
fn local_download_allowed(url: &reqwest::Url, api_base: &str) -> bool {
@@ -10,7 +10,9 @@ use gateway_admin::model::system::{
};
use gateway_admin::ports::system::{SystemOperationErrorKind, SystemOperations};
use gateway_core::engine::CancellationToken;
use gateway_host::system_update::{ProcessSystemOperations, SystemUpdateConfig};
use gateway_host::system_update::{
ProcessSystemOperations, SystemUpdateConfig, validate_download_url,
};
use sha2::{Digest as _, Sha256};
use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path};
@@ -202,6 +204,68 @@ async fn update_events_should_open_authenticated_sse_stream() {
assert!(stream.next().await.is_some());
}
#[tokio::test]
async fn update_events_should_preserve_complete_release_stage_sequence() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let events = service.update_events();
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
let steps = events
.map(|event| event.step.expect("release update step"))
.collect::<Vec<_>>()
.await;
assert_eq!(
steps,
[
"release", "prepare", "asset", "asset", "verify", "prepare", "download", "download",
"download", "checksum", "checksum", "extract", "extract", "replace", "replace", "done",
]
);
}
#[tokio::test]
async fn update_events_should_report_download_bytes_and_percent() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let events = service.update_events();
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
let progress = events
.filter_map(
|event| async move { event.progress_percent.map(|value| (value, event.message)) },
)
.collect::<Vec<_>>()
.await;
assert!(progress.iter().any(|(percent, message)| {
*percent == 100 && message.starts_with("已下载 ") && message.ends_with(" (100%)")
}));
}
#[tokio::test]
async fn update_should_fail_when_release_checksum_is_missing() {
let server = MockServer::start().await;
@@ -280,6 +344,17 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
);
}
#[test]
fn update_should_trust_github_release_asset_redirect_host() {
assert!(
validate_download_url(
"https://release-assets.githubusercontent.com/github-production-release-asset/archive",
"https://api.github.com/repos",
)
.is_ok()
);
}
#[tokio::test]
async fn update_should_reject_release_archive_with_unsafe_path() {
let server = MockServer::start().await;
@@ -23,7 +23,8 @@ const MAX_PAGE_SIZE: u16 = 100;
const MAX_FILTER_BYTES: usize = 256;
const MAX_SEARCH_BYTES: usize = 512;
const MAX_ACCOUNT_IDS: usize = 200;
const DASHBOARD_ACCOUNT_LIMIT: u16 = 50;
/// 概览卡只展示最近使用的四个账号;完整账号用量由账号管理页单独查询。
const DASHBOARD_ACCOUNT_LIMIT: u16 = 4;
const DIAGNOSTIC_LIMIT: i64 = 100;
const ACCOUNT_USAGE_TIMELINE_HOURS: i64 = 24;
@@ -660,7 +661,8 @@ pub struct OpsErrorRecord {
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub failure_kind: String,
pub status_code: Option<u16>,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub client_response_id: Option<String>,
pub upstream_request_id: Option<String>,
@@ -1626,7 +1628,8 @@ fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
upstream_model_id: error.upstream_model_id,
upstream_transport: error.upstream_transport,
failure_kind: error.failure_kind,
status_code: error.status_code,
client_status_code: error.client_status_code,
upstream_status_code: error.upstream_status_code,
provider_error_code: error.provider_error_code,
client_response_id: error.client_response_id,
upstream_request_id: error.upstream_request_id,
@@ -1977,8 +1980,12 @@ async fn provider_account_metrics(
let row = sqlx::query(
"select count(*)::bigint as total,
count(*) filter (where enabled)::bigint as enabled,
count(*) filter (where not enabled or availability <> 'ready')::bigint
as unavailable,
count(*) filter (
where not (
enabled and availability = 'ready'
and (access_token_expires_at is null or access_token_expires_at > $1)
)
)::bigint as unavailable,
count(*) filter (
where enabled and availability = 'ready'
and (access_token_expires_at is null or access_token_expires_at > $1)
@@ -2921,7 +2928,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
mr.provider_kind,
mr.provider_account_ref, mr.upstream_model_id, mr.upstream_transport,
coalesce(mr.error_kind, 'failed') as failure_kind,
coalesce(mr.upstream_status_code, mr.client_status_code) as status_code,
mr.client_status_code, mr.upstream_status_code,
mr.provider_error_code, mr.client_response_id, mr.upstream_request_id,
mr.latency_ms, coalesce(mr.error_message, mr.error_kind, 'request failed') as message,
1::integer as occurrence_count,
@@ -2932,7 +2939,8 @@ const OPS_ERRORS_CTE: &str = "with errors as (
select 'ops_event'::text, oe.id, oe.model_request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
oe.provider_kind, oe.provider_account_ref,
oe.upstream_model_id, null::text, oe.failure_kind, oe.status_code,
oe.upstream_model_id, null::text, oe.failure_kind,
null::integer as client_status_code, oe.status_code as upstream_status_code,
oe.provider_error_code, mr.client_response_id, oe.upstream_request_id,
oe.latency_ms, oe.message, oe.occurrence_count, oe.created_at,
'ops_event:' || oe.id
@@ -2947,7 +2955,8 @@ async fn list_ops_errors(pool: &PgPool, query: OpsErrorQuery) -> StoreResult<Ops
" select source, event_id, request_id, attempt_index, client_api_key_ref,
component, operation, provider_kind, provider_account_ref,
upstream_model_id, upstream_transport,
failure_kind, status_code, provider_error_code, client_response_id,
failure_kind, client_status_code, upstream_status_code,
provider_error_code, client_response_id,
upstream_request_id, latency_ms, message, occurrence_count, occurred_at,
stable_sort_id
from errors e where e.occurred_at >= ",
@@ -3046,7 +3055,7 @@ fn push_ops_filter(statement: &mut QueryBuilder<Postgres>, filter: &OpsErrorFilt
statement.push_bind(i32::try_from(index).unwrap_or(i32::MAX));
}
if let Some(status) = filter.status_code {
statement.push(" and e.status_code = ");
statement.push(" and e.upstream_status_code = ");
statement.push_bind(i32::from(status));
}
if let Some(search) = &filter.search {
@@ -3147,7 +3156,8 @@ fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
failure_kind: get(row, "failure_kind")?,
status_code: optional_status(row, "status_code")?,
client_status_code: optional_status(row, "client_status_code")?,
upstream_status_code: optional_status(row, "upstream_status_code")?,
provider_error_code: get(row, "provider_error_code")?,
client_response_id: get(row, "client_response_id")?,
upstream_request_id: get(row, "upstream_request_id")?,
@@ -1700,10 +1700,8 @@ fn admin_account_status(
.is_some_and(|expires_at| expires_at <= now)
{
AdminAccountStatus::Expired
} else if account.availability == ProviderAccountAvailability::Ready {
AdminAccountStatus::Active
} else {
AdminAccountStatus::Attention
AdminAccountStatus::Active
}
}
@@ -1728,7 +1726,7 @@ fn admin_account_summary(
.count(),
)
.unwrap_or(u64::MAX);
let attention = u64::try_from(
let unavailable = u64::try_from(
accounts
.iter()
.filter(|account| {
@@ -1746,7 +1744,7 @@ fn admin_account_summary(
total,
active,
quota_exhausted,
attention,
unavailable,
}
}
@@ -1797,7 +1795,6 @@ const fn admin_account_status_name(status: AdminAccountStatus) -> &'static str {
AdminAccountStatus::QuotaExhausted => "quota_exhausted",
AdminAccountStatus::Disabled => "disabled",
AdminAccountStatus::Banned => "banned",
AdminAccountStatus::Attention => "attention",
}
}
@@ -1,4 +1,4 @@
//! PostgreSQL 账号 cooldown 事实的可丢失 Redis 热缓存。
//! 请求调度用的可丢失账号 cooldown Redis 存储。
use std::{sync::Arc, time::SystemTime};
@@ -243,7 +243,7 @@ impl ProviderCooldownPort for RedisCredentialCooldownRepository {
}
}
/// PostgreSQL 账号事实到可丢失 cooldown cache 的 write-through adapter。
/// 账号状态写入到可丢失 cooldown cache 的桥接 adapter。
pub struct CooldownCachingProviderAccountStore {
authoritative: Arc<dyn ProviderAccountStore>,
cooldowns: Arc<dyn CredentialCooldownRepository>,
@@ -62,6 +62,57 @@ fn postgres_admin_observability_adapter_implements_terminal_port() {
assert_port::<PgAdminObservabilityStore>();
}
#[tokio::test]
async fn dashboard_account_metrics_should_partition_available_and_unavailable_accounts() {
let Some(database) = TestDatabase::create("dashboard_account_metrics").await else {
return;
};
let now = Utc::now();
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, upstream_user_id, authentication_kind,
provider_credentials_json, credential_revision, has_refresh_token,
access_token_expires_at, enabled, availability,
availability_observed_at, created_at, updated_at
) values
('acct_available', 'openai', 'available', 'user-available', 'oauth',
'{}'::jsonb, 1, false, $1 + interval '1 day', true, 'ready', $1, $1, $1),
('acct_expired', 'openai', 'expired', 'user-expired', 'oauth',
'{}'::jsonb, 1, false, $1 - interval '1 day', true, 'ready', $1, $1, $1),
('acct_banned', 'xai', 'banned', 'user-banned', 'oauth',
'{}'::jsonb, 1, false, $1 + interval '1 day', true, 'banned', $1, $1, $1),
('acct_disabled', 'xai', 'disabled', 'user-disabled', 'oauth',
'{}'::jsonb, 1, false, $1 + interval '1 day', false, 'ready', $1, $1, $1)",
)
.bind(now)
.execute(&database.pool)
.await
.expect("seed account metric states");
let repository = PgObservabilityRepository::new(database.pool.clone());
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("dashboard range");
let metrics = repository
.dashboard_summary(range)
.await
.expect("dashboard summary")
.provider_accounts;
assert_eq!(
(
metrics.total,
metrics.enabled,
metrics.active,
metrics.unavailable,
metrics.expired,
metrics.disabled,
metrics.banned,
),
(4, 3, 1, 3, 1, 1, 1),
);
database.close().await;
}
#[tokio::test]
async fn calculated_usage_billing_facts_keep_only_completed_calculated_costs() {
let Some(database) = TestDatabase::create("calculated_usage_billing_facts").await else {
@@ -589,6 +640,20 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
.await
.expect("ops errors");
assert_eq!(errors.total, 2);
let request_error = errors
.items
.iter()
.find(|error| error.source == "model_request")
.expect("request error");
assert_eq!(request_error.client_status_code, Some(502));
assert_eq!(request_error.upstream_status_code, Some(429));
let attempt_error = errors
.items
.iter()
.find(|error| error.source == "ops_event")
.expect("attempt error");
assert_eq!(attempt_error.client_status_code, None);
assert_eq!(attempt_error.upstream_status_code, Some(429));
database.close().await;
}
@@ -126,13 +126,13 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
beta.availability = ProviderAccountAvailability::Banned;
let mut charlie = account("acct_charlie", "user-charlie");
charlie.email = Some("charlie@example.invalid".to_owned());
let mut attention = account("acct_attention", "user-attention");
attention.email = Some("attention@example.invalid".to_owned());
attention.availability = ProviderAccountAvailability::Invalid;
let mut invalid = account("acct_invalid", "user-invalid");
invalid.email = Some("invalid@example.invalid".to_owned());
invalid.availability = ProviderAccountAvailability::Invalid;
let mut quota_exhausted = account("acct_quota_exhausted", "user-quota-exhausted");
quota_exhausted.email = Some("quota-exhausted@example.invalid".to_owned());
quota_exhausted.availability = ProviderAccountAvailability::QuotaExhausted;
for account in [alpha, beta, charlie, attention, quota_exhausted] {
for account in [alpha, beta, charlie, invalid, quota_exhausted] {
repository
.insert_provider_account(account)
.await
@@ -214,9 +214,8 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
assert_eq!(usage_page.config_revision.get(), 1);
assert_eq!(usage_page.total, 5);
assert_eq!(usage_page.summary.total, 5);
assert_eq!(usage_page.summary.active, 2);
assert_eq!(usage_page.summary.active, 3);
assert_eq!(usage_page.summary.quota_exhausted, 1);
assert_eq!(usage_page.summary.attention, 1);
assert_eq!(
usage_page
.items
@@ -277,19 +276,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
.await
.expect("filter banned accounts");
assert_eq!(banned.items[0].id, "acct_beta");
let attention = store
.list_accounts(AccountListQuery {
page: 1,
page_size: PageSize::new(10).expect("page size"),
provider_kind: None,
search: None,
status: Some(AccountStatus::Attention),
sort: None,
})
.await
.expect("filter attention accounts");
assert_eq!(attention.items[0].id, "acct_attention");
database.close().await;
}
@@ -215,7 +215,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
}
}
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 token。
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 credential secret。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
@@ -252,21 +252,47 @@ impl fmt::Debug for CodexCprExportDocument {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprExportAccount {
struct CodexCprExportCommon {
id: String,
email: Option<String>,
account_id: Option<String>,
user_id: Option<String>,
label: Option<String>,
plan_type: Option<String>,
token: String,
refresh_token: Option<String>,
access_token_expires_at: Option<String>,
status: &'static str,
added_at: String,
updated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprOAuthExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
token: String,
refresh_token: Option<String>,
access_token_expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprAgentIdentityExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
auth_mode: &'static str,
agent_runtime_id: String,
agent_private_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
AgentIdentity(CodexCprAgentIdentityExportAccount),
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
@@ -444,31 +470,51 @@ impl CodexCredentialAdmin {
}
let data = CodexCredentialCodec::decode_complete(&item.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let CodexCredentialData::OAuth(data) = data else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| account.has_refresh_token() != data.refresh_token.is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
accounts.push(CodexCprExportAccount {
let common = CodexCprExportCommon {
id: account.id().as_str().to_owned(),
email: account.email().map(str::to_owned),
account_id: account.upstream_account_id().map(str::to_owned),
user_id: Some(account.upstream_user_id().to_owned()),
label: Some(account.name().to_owned()),
plan_type: account.plan_type().map(str::to_owned),
token: data.access_token,
refresh_token: data.refresh_token,
access_token_expires_at: account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
status: cpr_status(&account),
added_at: china_rfc3339(item.added_at),
updated_at: china_rfc3339(item.updated_at),
});
};
let exported = match data {
CodexCredentialData::OAuth(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| account.has_refresh_token() != data.refresh_token.is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
common,
token: data.access_token,
refresh_token: data.refresh_token,
access_token_expires_at: account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
})
}
CodexCredentialData::AgentIdentity(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|| account.has_refresh_token()
|| account.access_token_expires_at().is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
common,
auth_mode: "agentIdentity",
agent_runtime_id: data.agent_runtime_id,
agent_private_key: data.agent_private_key,
task_id: data.task_id,
})
}
};
accounts.push(exported);
}
Ok(CodexCprExportDocument {
source_format: "cpr",
@@ -1034,7 +1080,25 @@ fn parse_import_document(
let values = import_account_values(payload)?;
let mut accounts = Vec::new();
for value in values {
let parsed = match shape {
// CPR 导出允许 OAuth 与 Agent Identity 共存;按账号选择 Provider
// 解析器,避免一个 Agent Identity 条目把整份文档误判成单一格式。
let account_shape = if looks_like_agent_identity_account(value) {
ImportDocumentShape::AgentIdentity
} else if shape == ImportDocumentShape::AgentIdentity {
if value.get("credentials").is_some() || value.get("tokens").is_some() {
ImportDocumentShape::CredentialBundle
} else if value
.as_object()
.is_some_and(auth_document_provider_is_openai)
{
ImportDocumentShape::AuthDocument
} else {
ImportDocumentShape::Native
}
} else {
shape
};
let parsed = match account_shape {
ImportDocumentShape::Native => Some(parse_native_account(value)?),
ImportDocumentShape::AgentIdentity => Some(parse_agent_identity_account(value)?),
ImportDocumentShape::CredentialBundle => parse_credential_bundle_account(value)?,
@@ -1076,26 +1140,37 @@ fn parse_agent_identity_account(
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
let auth_mode =
first_string(value, &["auth_mode"]).or_else(|| first_string(identity, &["auth_mode"]));
let auth_mode = first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
if auth_mode.as_deref() != Some("agentIdentity") {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let runtime_id = first_string(identity, &["agent_runtime_id"])
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let private_key = first_string(identity, &["agent_private_key"])
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(identity, &["email"]),
plan_type: first_string(identity, &["plan_type"]),
chatgpt_account_id: first_string(identity, &["chatgpt_account_id", "account_id"]),
chatgpt_user_id: first_string(identity, &["chatgpt_user_id"]),
plan_type: first_string(identity, &["plan_type", "planType"]),
chatgpt_account_id: first_string(
identity,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
),
chatgpt_user_id: first_string(
identity,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
authentication: ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id: first_string(identity, &["task_id"]),
task_id: first_string(identity, &["task_id", "taskId"]),
},
status: first_string(value, &["status"]),
})
@@ -1379,24 +1454,23 @@ fn looks_like_credential_bundle(value: &Value) -> bool {
fn looks_like_agent_identity_document(value: &Value) -> bool {
import_account_values(value).is_ok_and(|accounts| {
accounts.iter().any(|account| {
account
.get("auth_mode")
.and_then(Value::as_str)
.is_some_and(|mode| mode == "agentIdentity")
|| account
.get("agent_identity")
.and_then(|identity| identity.get("agent_runtime_id"))
.is_some()
|| account
.get("credentials")
.and_then(|credentials| credentials.get("auth_mode"))
.and_then(Value::as_str)
.is_some_and(|mode| mode == "agentIdentity")
})
accounts
.iter()
.any(|account| looks_like_agent_identity_account(account))
})
}
fn looks_like_agent_identity_account(value: &Value) -> bool {
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
.is_some_and(|mode| mode == "agentIdentity")
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
}
fn looks_like_auth_document(value: &Value) -> bool {
import_account_values(value).is_ok_and(|accounts| {
accounts.iter().any(|account| {
@@ -165,6 +165,9 @@ pub enum CodexCatalogVisibility {
Hide,
/// 不进入 picker。
None,
/// 上游新增的可见性值;保留模型条目但不作本地可见性判断。
#[serde(other)]
Unknown,
}
/// 一个已完整校验的 Codex 真实模型。
@@ -1,6 +1,7 @@
//! 核心 Generate operation 到 Codex Responses wire request 的严格编码。
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
@@ -127,64 +128,37 @@ struct ExtractedRequestContext {
impl ExtractedRequestContext {
fn from_body(body: &Map<String, Value>) -> Self {
Self {
turn_state: body_or_metadata_string(
body,
&["turnState", "turn_state", "x-codex-turn-state"],
),
turn_metadata: body_or_metadata_string(
body,
&["turnMetadata", "turn_metadata", "x-codex-turn-metadata"],
),
beta_features: body_or_metadata_string(body, &["betaFeatures", "beta_features"]),
version: body_or_metadata_string(body, &["version"]),
include_timing_metrics: body_or_metadata_string(
body,
&["includeTimingMetrics", "include_timing_metrics"],
),
codex_window_id: body_or_metadata_string(
body,
&["codexWindowId", "codex_window_id", "x-codex-window-id"],
),
parent_thread_id: body_or_metadata_string(
body,
&[
"parentThreadId",
"parent_thread_id",
"x-codex-parent-thread-id",
],
),
conversation_id: body_or_metadata_string(body, &["conversation_id", "conversationId"]),
session_id: body_or_metadata_string(body, &["session_id", "sessionId"]),
thread_id: body_or_metadata_string(body, &["thread_id", "threadId"]),
client_request_id: body_or_metadata_string(
body,
&[
"x-client-request-id",
"client_request_id",
"clientRequestId",
],
),
turn_id: body_or_metadata_string(body, &["turn_id", "turnId", "x-codex-turn-id"]),
responses_lite: body_or_metadata_string(
body,
&["ws_request_header_x_openai_internal_codex_responses_lite"],
),
memgen_request: body_or_metadata_string(
body,
&["x-openai-memgen-request", "memgen_request"],
),
turn_state: body_string(body, "turnState"),
turn_metadata: body_string(body, "turnMetadata"),
beta_features: body_string(body, "betaFeatures"),
version: body_string(body, "version"),
include_timing_metrics: body_string(body, "includeTimingMetrics"),
codex_window_id: body_string(body, "codexWindowId"),
parent_thread_id: body_string(body, "parentThreadId"),
conversation_id: body_string(body, "conversation_id"),
session_id: body_string(body, "session_id"),
thread_id: body_string(body, "thread_id"),
client_request_id: body_string(body, "x-client-request-id"),
turn_id: body_string(body, "turn_id"),
// Responses Lite 在 WebSocket body 中的这个键是官方 header 投影,
// 不是普通上下文字段的 metadata 回退。
responses_lite: body
.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| {
non_empty_string(
metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY),
)
}),
// Memory consolidation 只由官方请求头提供;body 中同名字段不参与
// transport 事实提取。
memgen_request: None,
}
}
}
fn body_or_metadata_string(body: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| non_empty_string(body.get(*key)))
.or_else(|| {
let metadata = body.get("client_metadata")?.as_object()?;
keys.iter()
.find_map(|key| non_empty_string(metadata.get(*key)))
})
fn body_string(body: &Map<String, Value>, key: &str) -> Option<String> {
non_empty_string(body.get(key))
}
fn non_empty_string(value: Option<&Value>) -> Option<String> {
@@ -528,10 +502,12 @@ fn apply_protocol_context(request: &mut CodexResponsesRequest, context: &Map<Str
.parent_thread_id
.take()
.or_else(|| context_string(context, "parent_thread_id"));
let prompt_cache_key = request.prompt_cache_key().map(ToOwned::to_owned);
request.client_conversation_id = request
.client_conversation_id
.take()
.or_else(|| context_string(context, "conversation_id"));
.or_else(|| context_string(context, "conversation_id"))
.or(prompt_cache_key);
request.client_session_id = request
.client_session_id
.take()
@@ -3,23 +3,31 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use chrono::{TimeZone, Utc};
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use futures::future::BoxFuture;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, PlaintextCredential, ProviderAccountId,
AccountAvailability, CredentialRevision, LoadedCredential, PlaintextCredential,
ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshLeaseRequest,
ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OFFICIAL_CODEX_BASE_URL;
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use provider_openai::credential::{
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexCredentialAdmin,
CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec,
CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError,
CodexJwtIdentityVerifier, ExportManagedCodexCredential, ImportCodexOAuthCredential,
ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, RotateManagedCodexCredential,
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAccountIdentityService,
CodexAccountIdentityVerifier, CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData,
CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService,
CodexCredentialCodec, CodexIdentityExpectation, CodexIdentityVerification,
CodexIdentityVerificationError, CodexJwtIdentityVerifier, ExportManagedCodexCredential,
ImportCodexOAuthCredential, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource,
RotateManagedCodexCredential,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use secrecy::{ExposeSecret, SecretString};
@@ -137,6 +145,60 @@ fn export_item(id: &str, token: &str) -> ExportManagedCodexCredential {
}
}
fn agent_export_item(id: &str) -> (ExportManagedCodexCredential, String) {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let private_key = STANDARD.encode(
signing_key
.to_pkcs8_der()
.expect("encode Agent Identity key")
.as_bytes(),
);
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: 1,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: "00000000-0000-4000-8000-000000000007".to_owned(),
agent_runtime_id: "runtime-export".to_owned(),
agent_private_key: private_key.clone(),
task_id: Some("task-export".to_owned()),
cookies: Vec::new(),
})
.expect("encode Agent Identity credential");
let account = ProviderAccount::new(
ProviderAccountId::new(id.to_owned()).expect("account id"),
ProviderKind::new("openai").expect("provider"),
format!("Agent Identity {id}"),
"agent-user-export".to_owned(),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_profile(
Some("agent-export@example.com".to_owned()),
Some("agent-account-export".to_owned()),
Some("pro".to_owned()),
)
.with_runtime_state(true, AccountAvailability::Ready, None)
.with_refresh_schedule(false, None);
(
ExportManagedCodexCredential {
current: LoadedCredential {
account,
credential,
},
added_at: Utc
.with_ymd_and_hms(2026, 7, 18, 2, 47, 1)
.single()
.expect("added at"),
updated_at: Utc
.with_ymd_and_hms(2026, 7, 19, 3, 0, 0)
.single()
.expect("updated at"),
},
private_key,
)
}
#[test]
fn cpr_export_matches_the_canonical_real_document_shape() {
let document = CodexCredentialAdmin
@@ -211,6 +273,57 @@ fn cpr_export_batch_validation_and_debug_are_secret_safe() {
assert!(debug.contains("account_count: 1"));
}
#[test]
fn cpr_export_preserves_agent_identity_material_without_oauth_fields() {
let (item, private_key) = agent_export_item("acct_agent_export");
let value = CodexCredentialAdmin
.format_cpr_export(vec![item])
.expect("Agent Identity export")
.into_json()
.expect("serialize Agent Identity export");
let account = &value["accounts"][0];
assert_eq!(account["authMode"], "agentIdentity");
assert_eq!(account["agentRuntimeId"], "runtime-export");
assert!(
account["agentPrivateKey"]
.as_str()
.is_some_and(|value| value == private_key)
);
assert_eq!(account["taskId"], "task-export");
assert!(account.get("token").is_none());
assert!(account.get("refreshToken").is_none());
assert!(account.get("accessTokenExpiresAt").is_none());
}
#[tokio::test]
async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() {
let (agent, _) = agent_export_item("acct_mixed_agent");
let value = CodexCredentialAdmin
.format_cpr_export(vec![
export_item("acct_mixed_oauth", "token-acct_mixed_oauth"),
agent,
])
.expect("mixed export")
.into_json()
.expect("serialize mixed export");
let prepared = import_service(unused_import_refresher())
.prepare_import_document(value)
.await
.expect("mixed CPR import");
assert_eq!(prepared.accounts().len(), 2);
assert!(prepared.accounts().iter().any(|account| {
account.account.authentication_kind() == CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
}));
assert!(
prepared
.accounts()
.iter()
.any(|account| { account.account.authentication_kind() == "oauth" })
);
}
pub(super) struct ManualRefresher {
outcome: Mutex<Option<Result<TokenPair, RefreshFailure>>>,
seen: Mutex<Vec<String>>,
@@ -1,9 +1,9 @@
use chrono::{TimeZone, Utc};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::{
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexClientError, CodexModelCatalogError,
CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES, build_reqwest_client,
parse_codex_model_catalog,
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexCatalogVisibility, CodexClientError,
CodexModelCatalogError, CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES,
build_reqwest_client, parse_codex_model_catalog,
};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -172,6 +172,20 @@ fn unknown_input_modality_should_degrade_capability_evidence_to_unknown() {
);
}
#[test]
fn unknown_visibility_should_degrade_without_rejecting_the_snapshot() {
let snapshot = parse_codex_model_catalog(
br#"{"models":[{"slug":"gpt-future","display_name":"Future","visibility":"preview_only"}]}"#,
None,
)
.expect("future visibility should not reject the snapshot");
assert_eq!(
snapshot.models()[0].metadata().visibility(),
Some(CodexCatalogVisibility::Unknown)
);
}
#[test]
fn invalid_request_slug_should_fail_the_entire_snapshot() {
let result = parse_codex_model_catalog(
@@ -126,6 +126,87 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
);
}
#[test]
fn encoder_should_restore_conversation_fallback_after_body_and_context_values() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
("prompt_cache_key".to_owned(), json!("cache-conversation")),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
Some("cache-conversation")
);
}
#[test]
fn encoder_should_ignore_legacy_context_aliases_and_metadata_fallbacks() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
("turn_state".to_owned(), json!("legacy-turn-state")),
(
"x-codex-turn-state".to_owned(),
json!("legacy-header-turn-state"),
),
("turn_metadata".to_owned(), json!("legacy-turn-metadata")),
("beta_features".to_owned(), json!("legacy-beta")),
("include_timing_metrics".to_owned(), json!("legacy-timing")),
("codex_window_id".to_owned(), json!("legacy-window")),
(
"x-codex-window-id".to_owned(),
json!("legacy-header-window"),
),
("parent_thread_id".to_owned(), json!("legacy-parent")),
(
"x-codex-parent-thread-id".to_owned(),
json!("legacy-header-parent"),
),
("conversationId".to_owned(), json!("legacy-conversation")),
("sessionId".to_owned(), json!("legacy-session")),
("threadId".to_owned(), json!("legacy-thread")),
("client_request_id".to_owned(), json!("legacy-request")),
("clientRequestId".to_owned(), json!("legacy-camel-request")),
("turnId".to_owned(), json!("legacy-turn")),
("x-codex-turn-id".to_owned(), json!("legacy-header-turn")),
(
"client_metadata".to_owned(),
json!({
"turnState": "metadata-turn-state",
"turnMetadata": "metadata-turn-metadata",
"conversation_id": "metadata-conversation",
"session_id": "metadata-session",
"thread_id": "metadata-thread",
"x-codex-window-id": "metadata-window"
}),
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
(
encoded.turn_state,
encoded.turn_metadata,
encoded.beta_features,
encoded.include_timing_metrics,
encoded.codex_window_id,
encoded.parent_thread_id,
encoded.client_conversation_id,
encoded.client_session_id,
encoded.client_thread_id,
encoded.client_request_id,
encoded.client_turn_id,
),
(
None, None, None, None, None, None, None, None, None, None, None,
)
);
}
#[test]
fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
let payload = ProtocolPayload::json_object(
+54 -11
View File
@@ -11,8 +11,9 @@ use gateway_admin::model::accounts::{
AccountAvailability as AdminAccountAvailability, AccountRecord,
};
use gateway_admin::model::observability::{
CalculatedBillingBreakdown, CurrencyCost, DashboardWireAttribute, DashboardWireProfile,
DashboardWireTarget, DecimalAmount, ProviderBillingInput,
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
ProviderBillingInput,
};
use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwner, AuthorizationOwnerBinding,
@@ -57,6 +58,7 @@ use crate::credential::{
PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist,
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
};
use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown};
const PENDING_SCHEMA_VERSION: u64 = 2;
@@ -66,6 +68,7 @@ const MAX_PENDING_TEXT_BYTES: usize = 512;
pub(crate) struct XaiAdminProvider {
provider_kind: ProviderKind,
wire_profile: XaiWireProfileState,
cli_release: GrokCliReleaseStatus,
accounts: Arc<dyn ProviderAccountStore>,
repository: GrokCredentialRepository,
oauth_config: GrokOAuthConfig,
@@ -95,10 +98,12 @@ impl XaiAdminProvider {
wire_profile: XaiWireProfileState,
accounts: Arc<dyn ProviderAccountStore>,
services: XaiAdminServices,
cli_release: GrokCliReleaseStatus,
) -> Self {
Self {
provider_kind,
wire_profile,
cli_release,
accounts,
repository: services.repository,
oauth_config: services.oauth_config,
@@ -216,34 +221,42 @@ impl ProviderAdmin for XaiAdminProvider {
}
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile> {
let profile = self.wire_profile.snapshot();
let release = dashboard_cli_release(&profile.client_version, self.cli_release.snapshot());
Some(DashboardWireProfile {
provider: self.provider_kind.as_str().to_owned(),
product: "Grok Build".to_owned(),
version: self.wire_profile.client_version(),
version: profile.client_version.clone(),
build: None,
target: DashboardWireTarget {
os_type: self.wire_profile.target_os(),
os_type: profile.target_os.clone(),
os_version: "—".to_owned(),
arch: self.wire_profile.target_arch(),
terminal: self.wire_profile.client_mode(),
arch: profile.target_arch.clone(),
terminal: profile.client_mode.clone(),
},
user_agent: self.wire_profile.user_agent(),
user_agent: format!(
"{}/{} ({}; {})",
profile.client_identifier,
profile.client_version,
profile.target_os,
profile.target_arch
),
attributes: vec![
DashboardWireAttribute {
label: "客户端标识".to_owned(),
value: self.wire_profile.client_identifier(),
value: profile.client_identifier,
},
DashboardWireAttribute {
label: "运行模式".to_owned(),
value: self.wire_profile.client_mode(),
value: profile.client_mode,
},
DashboardWireAttribute {
label: "Token 认证".to_owned(),
value: "xai-grok-cli".to_owned(),
},
],
verified_at: Some(self.wire_profile.verified_at()),
release: None,
verified_at: Some(profile.verified_at),
release: Some(release),
})
}
@@ -771,6 +784,36 @@ fn validate_account_record(
Ok(())
}
fn dashboard_cli_release(
profile_version: &str,
snapshot: GrokCliReleaseSnapshot,
) -> DashboardDesktopRelease {
let status = if snapshot.checked_at.is_none() {
DesktopReleaseStatus::Unchecked
} else if snapshot.last_error.is_some() {
DesktopReleaseStatus::Failed
} else if snapshot.latest_version.as_deref() == Some(profile_version) {
DesktopReleaseStatus::Current
} else if snapshot.latest_version.is_some() {
DesktopReleaseStatus::UpdateAvailable
} else {
DesktopReleaseStatus::Failed
};
DashboardDesktopRelease {
status,
checked_at: snapshot.checked_at,
latest_version: snapshot.latest_version,
latest_build: None,
published_at: None,
minimum_system_version: None,
hardware_requirements: None,
download_url: None,
download_size: None,
signature_present: None,
error: snapshot.last_error,
}
}
fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, ProviderAdminError> {
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
@@ -12,9 +12,10 @@ use gateway_core::engine::credential::{
AccountAvailability, CredentialRevision, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderStoreError, provider_refresh_retry_at,
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
ProviderLeaseRequest, ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest,
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError,
provider_refresh_retry_at,
};
use super::catalog::GrokCredentialCatalogService;
@@ -36,6 +37,7 @@ const MAX_REFRESH_EXCLUSIONS: usize = 400;
const MAX_SECRET_BYTES: usize = 64 * 1_024;
const DISCOVERY_NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5);
const REFRESH_RETRY_DELAY: Duration = Duration::from_secs(10 * 60);
const REFRESH_AMBIGUITY_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
/// 一个到期且已按 revision 读取明文 RT 的 xAI account。
pub struct DueGrokCredential {
@@ -274,6 +276,7 @@ pub struct GrokCredentialRefreshService {
refresher: Arc<dyn GrokCredentialRefresher>,
catalog: Arc<GrokCredentialCatalogService>,
leases: Arc<dyn ProviderLeasePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
}
@@ -283,6 +286,7 @@ impl GrokCredentialRefreshService {
refresher: Arc<dyn GrokCredentialRefresher>,
catalog: Arc<GrokCredentialCatalogService>,
leases: Arc<dyn ProviderLeasePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
) -> Self {
Self {
@@ -290,6 +294,7 @@ impl GrokCredentialRefreshService {
refresher,
catalog,
leases,
cooldowns,
runtime_policy,
}
}
@@ -297,9 +302,6 @@ impl GrokCredentialRefreshService {
pub async fn refresh_due(
&self,
) -> Result<Vec<GrokCredentialRefreshOutcome>, GrokCredentialRefreshError> {
if !self.prepare_cycle_if_due().await? {
return Ok(Vec::new());
}
self.refresh_due_excluding(&[]).await
}
@@ -455,8 +457,21 @@ impl GrokCredentialRefreshService {
.into_iter()
.map(|account_id| GrokCredentialRefreshOutcome::Failed { account_id })
.collect::<Vec<_>>();
outcomes.reserve(batch.credentials.len());
let refreshed = stream::iter(batch.credentials.into_iter().map(|credential| async move {
let mut credentials = Vec::with_capacity(batch.credentials.len());
for credential in batch.credentials {
if !self.runtime_cooldown_active(&credential).await {
credentials.push(credential);
}
}
if credentials.is_empty() {
return Ok(outcomes);
}
self.refresher
.prepare_cycle()
.await
.map_err(|_| GrokCredentialRefreshError::Preparation)?;
outcomes.reserve(credentials.len());
let refreshed = stream::iter(credentials.into_iter().map(|credential| async move {
let account_id = credential.account_id.clone();
(
account_id,
@@ -539,14 +554,11 @@ impl GrokCredentialRefreshService {
)
.await
}
Err(GrokRefreshFailure::Ambiguous) => self
.persist_terminal_failure(
credential,
GrokCredentialAvailability::Invalid,
"refresh_ambiguous",
)
.await
.map(|_| GrokCredentialRefreshOutcome::Ambiguous { account_id }),
Err(GrokRefreshFailure::Ambiguous) => {
self.record_runtime_cooldown(&credential, REFRESH_AMBIGUITY_COOLDOWN)
.await;
Ok(GrokCredentialRefreshOutcome::Ambiguous { account_id })
}
Err(GrokRefreshFailure::Transient) => {
if self
.persist_backoff(&credential, "refresh-transient")
@@ -644,6 +656,36 @@ impl GrokCredentialRefreshService {
})
}
async fn runtime_cooldown_active(&self, credential: &DueGrokCredential) -> bool {
let Ok(Some(cooldown)) = self.cooldowns.read(credential.account_id()).await else {
return false;
};
if cooldown.credential_revision() != credential.credential_revision() {
if cooldown.credential_revision() < credential.credential_revision() {
let _ = self
.cooldowns
.clear(credential.account_id(), credential.credential_revision())
.await;
}
return false;
}
cooldown.until() > SystemTime::now()
}
async fn record_runtime_cooldown(&self, credential: &DueGrokCredential, duration: Duration) {
let cooldown_until = SystemTime::now()
.checked_add(duration)
.unwrap_or_else(SystemTime::now);
let _ = self
.cooldowns
.put_if_later(ProviderCooldown::new(
credential.account_id.clone(),
credential.credential_revision,
cooldown_until,
))
.await;
}
async fn persist_terminal_failure(
&self,
credential: DueGrokCredential,
@@ -721,10 +763,12 @@ impl GrokCredentialRecovery for GrokCredentialRefreshService {
GrokCredentialRefreshOutcome::Refreshed { .. }
| GrokCredentialRefreshOutcome::Stale { .. },
) => GrokCredentialRecoveryOutcome::Recovered,
Ok(
GrokCredentialRefreshOutcome::Invalidated { .. }
| GrokCredentialRefreshOutcome::Ambiguous { .. },
) => GrokCredentialRecoveryOutcome::Rejected,
Ok(GrokCredentialRefreshOutcome::Invalidated { .. }) => {
GrokCredentialRecoveryOutcome::Rejected
}
Ok(GrokCredentialRefreshOutcome::Ambiguous { .. }) => {
GrokCredentialRecoveryOutcome::Unavailable
}
Ok(
GrokCredentialRefreshOutcome::Transient { .. }
| GrokCredentialRefreshOutcome::Rejected { .. }
@@ -7,10 +7,11 @@ use std::time::{Duration, SystemTime};
use chrono::Utc;
use gateway_core::engine::credential::{
AccountCandidate, AccountFeedbackStats, AccountSelectionContext, AccountSelector,
ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderSchedulingLeaseRequest,
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
};
use gateway_core::routing::ProviderKind;
@@ -26,6 +27,7 @@ use crate::{
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
const STREAM_INTERRUPTION_COOLDOWN: Duration = Duration::from_secs(30);
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(60);
/// 仅经 Core account port、TTL catalog cache 和 Redis lease 选择一个 OAuth session。
pub struct GrokAccountSessionSelector {
@@ -34,6 +36,7 @@ pub struct GrokAccountSessionSelector {
catalog_cache: Arc<dyn GrokCredentialCatalogCache>,
quota: Arc<GrokCredentialQuotaService>,
scheduling: Arc<dyn ProviderLeasePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
account_feedback: Arc<AccountFeedbackStats>,
}
@@ -45,6 +48,7 @@ impl GrokAccountSessionSelector {
catalog_cache: Arc<dyn GrokCredentialCatalogCache>,
quota: Arc<GrokCredentialQuotaService>,
scheduling: Arc<dyn ProviderLeasePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
account_feedback: Arc<AccountFeedbackStats>,
) -> Self {
Self {
@@ -53,6 +57,7 @@ impl GrokAccountSessionSelector {
catalog_cache,
quota,
scheduling,
cooldowns,
account_feedback,
}
}
@@ -94,8 +99,10 @@ impl GrokAccountSessionSelector {
.load_state(&self.provider_kind, &account_ids)
.await
.map_err(|_| GrokSessionSelectorError::Unavailable)?;
let runtime_cooldowns = self.runtime_cooldowns(&catalog_eligible).await;
let mut candidates = catalog_eligible
.into_iter()
.filter(|account| !runtime_cooldowns.contains(account.id()))
.map(|account| {
let health = self
.account_feedback
@@ -195,6 +202,43 @@ impl GrokAccountSessionSelector {
Err(GrokSessionSelectorError::NoEligibleSession)
}
}
async fn runtime_cooldowns(
&self,
accounts: &[ProviderAccount],
) -> std::collections::BTreeSet<ProviderAccountId> {
let now = SystemTime::now();
let mut cooled = std::collections::BTreeSet::new();
for account in accounts {
let Ok(Some(cooldown)) = self.cooldowns.read(account.id()).await else {
continue;
};
if cooldown.credential_revision() != account.revision() {
if cooldown.credential_revision() < account.revision() {
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
}
continue;
}
if cooldown.until() > now {
cooled.insert(account.id().clone());
}
}
cooled
}
async fn record_runtime_cooldown(&self, session: &SelectedGrokSession, duration: Duration) {
let cooldown_until = SystemTime::now()
.checked_add(duration)
.unwrap_or_else(SystemTime::now);
let _ = self
.cooldowns
.put_if_later(ProviderCooldown::new(
session.account_id().clone(),
session.credential_revision(),
cooldown_until,
))
.await;
}
}
impl fmt::Debug for GrokAccountSessionSelector {
@@ -205,6 +249,7 @@ impl fmt::Debug for GrokAccountSessionSelector {
.field("catalog_cache", &"[TTL_CACHE]")
.field("quota", &self.quota)
.field("scheduling", &"[SCHEDULING_PORT]")
.field("cooldowns", &"[COOLDOWN_PORT]")
.finish()
}
}
@@ -221,12 +266,12 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
) -> GrokCredentialFeedbackFuture<'a> {
Box::pin(async move {
let observed_at = Utc::now();
let (availability, reason, cooldown_until) = match failure {
GrokCredentialFailure::Unauthorized => (
GrokCredentialAvailability::Invalid,
"upstream_unauthorized",
None,
),
let persistent = match failure {
GrokCredentialFailure::Unauthorized => {
self.record_runtime_cooldown(session, UNAUTHORIZED_COOLDOWN)
.await;
return;
}
GrokCredentialFailure::QuotaExhausted => (
GrokCredentialAvailability::QuotaExhausted,
"upstream_quota_exhausted",
@@ -236,26 +281,16 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
let retry_after = retry_after
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
.min(MAX_RATE_LIMIT_COOLDOWN);
let cooldown_until = chrono::Duration::from_std(retry_after)
.ok()
.and_then(|duration| observed_at.checked_add_signed(duration));
(
GrokCredentialAvailability::Cooldown,
"upstream_rate_limited",
cooldown_until,
)
self.record_runtime_cooldown(session, retry_after).await;
return;
}
GrokCredentialFailure::StreamInterrupted => {
let cooldown_until = chrono::Duration::from_std(STREAM_INTERRUPTION_COOLDOWN)
.ok()
.and_then(|duration| observed_at.checked_add_signed(duration));
(
GrokCredentialAvailability::Cooldown,
"upstream_stream_interrupted",
cooldown_until,
)
self.record_runtime_cooldown(session, STREAM_INTERRUPTION_COOLDOWN)
.await;
return;
}
};
let (availability, reason, cooldown_until) = persistent;
let _ = self
.repository
.update_state(&UpdateGrokCredentialState {
+11 -5
View File
@@ -60,12 +60,13 @@ pub use transport::{
GrokCatalogModel, GrokClientIdentity, GrokCompactionDecodeError, GrokCompactionRequest,
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokCredentialFeedbackFuture,
GrokDnsResolutionError, GrokDnsResolutionPlan, GrokDnsResolutionPolicy, GrokEndpointPolicy,
GrokHeader, GrokHeaderValue, GrokInferenceChunkStream, GrokInferenceRequest,
GrokHeader, GrokHeaderValue, GrokInferenceChunkStream, GrokInferenceClientCacheStatus,
GrokInferenceDnsObservation, GrokInferenceDnsSource, GrokInferenceRequest,
GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError,
GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokModelCatalogClient,
GrokModelCatalogError, GrokModelCatalogRequest, GrokModelCatalogSession,
GrokModelCatalogSessionError, GrokModelCatalogSnapshot, GrokModelCatalogTransport,
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokInferenceTransportMetrics,
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokProviderConfigError,
GrokProviderTransport, GrokRequestEncodeError, GrokReqwestTransportBuildError,
GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError,
@@ -99,10 +100,12 @@ pub async fn initialize(
OfficialGrokCliReleaseTransport::new().map_err(|_| XaiInitializeError::Transport)?,
),
));
let cli_release_status = cli_release.status();
let provider_kind =
ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| XaiInitializeError::ProviderKind)?;
let accounts: Arc<dyn ProviderAccountStore> = ports.accounts();
let leases = ports.leases();
let cooldowns = ports.cooldowns();
let account_feedback = ports.account_feedback();
let runtime_policy = ports.runtime_policy();
let repository = GrokCredentialRepository::new(Arc::clone(&accounts));
@@ -135,6 +138,7 @@ pub async fn initialize(
catalog_cache,
Arc::clone(&quota),
Arc::clone(&leases),
Arc::clone(&cooldowns),
Arc::clone(&account_feedback),
));
let inference: Arc<dyn GrokInferenceTransport> = Arc::new(
@@ -162,6 +166,7 @@ pub async fn initialize(
refresher,
Arc::clone(&catalog),
Arc::clone(&leases),
cooldowns,
Arc::clone(&runtime_policy),
));
let credential_recovery: Arc<dyn GrokCredentialRecovery> = refresh.clone();
@@ -190,6 +195,7 @@ pub async fn initialize(
catalog: Arc::clone(&catalog),
runtime_policy,
},
cli_release_status,
));
let worker_contributions = provider::worker_contributions(
refresh,
+235 -84
View File
@@ -18,13 +18,19 @@ use gateway_core::engine::provider::{
UpstreamTransport,
};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendState};
use gateway_core::error::{ContinuationFailure, ProviderError, ProviderErrorKind};
use gateway_core::event::{GatewayEvent, ProviderEvent, ProviderResponseObservation, ResponseMeta};
use gateway_core::error::{
ClientVisibleUpstreamError, ContinuationFailure, ProviderError, ProviderErrorKind,
};
use gateway_core::event::{
GatewayEvent, ProviderEvent, ProviderResponseMetadata, ProviderResponseObservation,
ProviderResponseTimings, ResponseMeta,
};
use gateway_core::operation::{
Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState,
};
use gateway_core::routing::{
ModelCapabilities, ProviderCandidate, ProviderKind, SupportLevel, UpstreamModelId,
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, SupportLevel,
UpstreamModelId,
};
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId,
@@ -35,7 +41,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use url::Url;
use crate::GrokCatalogCapabilityEvidence;
use crate::XaiWireProfileState;
use crate::credential::{
GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService,
@@ -50,12 +55,14 @@ use crate::transport::{
GROK_RESPONSES_URL, GrokCompactionDecodeError, GrokCompactionRequest,
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokInferenceChunkStream,
GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokProviderConfigError,
GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionSelection,
GrokSessionSelector, GrokSessionSelectorError, SelectedGrokSession,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportMetrics,
GrokProviderConfigError, GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey,
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, SelectedGrokSession,
};
use crate::{GrokCatalogCapabilityEvidence, GrokCatalogModel};
const HTTP_SSE_TRANSPORT: &str = "http_sse";
const DEFAULT_GROK_MODEL: &str = "grok-4.5";
const XAI_SESSION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024;
const XAI_SESSION_OUTPUT_LIMIT: usize = 4_096;
const MIN_REASONING_CIPHERTEXT_BYTES: usize = 50;
@@ -138,38 +145,15 @@ impl Provider for GrokBuildProvider {
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
let models = self.catalog.query_models().await.map_err(|_| {
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
})?;
let Ok(models) = self.catalog.query_models().await else {
return Ok(vec![default_grok_model_capabilities()?]);
};
if models.is_empty() {
return Ok(vec![default_grok_model_capabilities()?]);
}
Ok(models
.into_iter()
.map(|model| {
let mut operations = BTreeSet::new();
if model.capabilities().responses_api()
== GrokCatalogCapabilityEvidence::DeclaredNative
{
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(
operations,
model
.limits()
.max_output_tokens()
.map(std::num::NonZeroU64::get),
)
.with_feature(
Feature::Reasoning,
support(model.capabilities().reasoning_effort()),
)
.with_feature(
Feature::Tools,
tool_support(model.capabilities().streaming_tool_calls()),
)
.with_feature(Feature::Vision, SupportLevel::Unknown)
.with_feature(Feature::JsonSchema, SupportLevel::Unknown)
.with_feature(Feature::NativeContinuation, SupportLevel::Native);
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
})
.map(compile_grok_model_capabilities)
.collect())
}
@@ -403,6 +387,112 @@ fn tool_support(evidence: GrokCatalogCapabilityEvidence) -> SupportLevel {
}
}
fn compile_grok_model_capabilities(model: GrokCatalogModel) -> ProviderModelCapabilities {
let mut operations = BTreeSet::new();
if model.capabilities().responses_api() == GrokCatalogCapabilityEvidence::DeclaredNative {
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(
operations,
model
.limits()
.max_output_tokens()
.map(std::num::NonZeroU64::get),
)
.with_feature(
Feature::Reasoning,
support(model.capabilities().reasoning_effort()),
)
.with_feature(
Feature::Tools,
tool_support(model.capabilities().streaming_tool_calls()),
)
.with_feature(Feature::Vision, SupportLevel::Unknown)
.with_feature(Feature::JsonSchema, SupportLevel::Unknown)
.with_feature(Feature::NativeContinuation, SupportLevel::Native);
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
.with_presentation(grok_model_presentation(&model))
}
fn default_grok_model_capabilities() -> Result<ProviderModelCapabilities, ProviderError> {
let model = UpstreamModelId::new(DEFAULT_GROK_MODEL.to_owned())
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
.with_upstream_feature_validation();
Ok(ProviderModelCapabilities::new(model, capabilities)
.with_presentation(default_grok_model_presentation()))
}
fn default_grok_model_presentation() -> ModelPresentation {
ModelPresentation::new(
Some("Grok 4.5".to_owned()),
Some("xAI Grok 4.5 frontier model with reasoning and vision.".to_owned()),
)
.with_reasoning(
Some("medium".to_owned()),
["low", "medium", "high"].map(str::to_owned).to_vec(),
)
.with_context_window_tokens(Some(500_000))
.with_image_input(true)
.with_agent_tools(true, true)
}
fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
let slug = model.request_model().as_str();
let known_grok_4_5 = matches!(
slug,
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
);
let reasoning_evidence = model.capabilities().reasoning_effort();
let (default_reasoning, reasoning_efforts) = match reasoning_evidence {
GrokCatalogCapabilityEvidence::DeclaredNative => (
Some("medium".to_owned()),
["low", "medium", "high"].map(str::to_owned).to_vec(),
),
GrokCatalogCapabilityEvidence::DeclaredUnsupported => {
(Some("none".to_owned()), vec!["none".to_owned()])
}
GrokCatalogCapabilityEvidence::Unknown if known_grok_4_5 => (
Some("medium".to_owned()),
["low", "medium", "high"].map(str::to_owned).to_vec(),
),
GrokCatalogCapabilityEvidence::Unknown => (None, Vec::new()),
};
let context_window_tokens = model
.limits()
.context_window_tokens()
.map(std::num::NonZeroU64::get)
.or(known_grok_4_5.then_some(500_000));
let tool_evidence = model.capabilities().streaming_tool_calls();
ModelPresentation::new(
model
.display_name()
.map(str::to_owned)
.or_else(|| known_grok_4_5.then(|| "Grok 4.5".to_owned())),
model
.metadata()
.description()
.map(str::to_owned)
.or_else(|| {
known_grok_4_5
.then(|| "xAI Grok 4.5 frontier model with reasoning and vision.".to_owned())
}),
)
.with_reasoning(default_reasoning, reasoning_efforts)
.with_context_window_tokens(context_window_tokens)
.with_image_input(known_grok_4_5)
.with_agent_tools(
tool_evidence != GrokCatalogCapabilityEvidence::DeclaredUnsupported,
tool_evidence == GrokCatalogCapabilityEvidence::DeclaredNative
|| (known_grok_4_5 && tool_evidence == GrokCatalogCapabilityEvidence::Unknown),
)
.with_search_tool(
model.capabilities().backend_search() == GrokCatalogCapabilityEvidence::DeclaredNative,
)
.with_hidden(model.metadata().hidden().unwrap_or(false))
}
#[derive(Clone, Serialize, Deserialize)]
struct XaiSessionState {
account_id: String,
@@ -692,16 +782,12 @@ fn attach_xai_session_update(
.into_iter()
.map(XaiReplayItem::ClientInput),
);
transcript.extend(
capture
.output_items
.into_values()
.filter_map(|item| portable_output_item(item, false))
.map(|item| XaiReplayItem::AccountOutput {
account_id: capture.account_id.clone(),
item,
}),
);
transcript.extend(capture.output_items.into_values().map(|item| {
XaiReplayItem::AccountOutput {
account_id: capture.account_id.clone(),
item,
}
}));
let state = XaiSessionState {
account_id: capture.account_id,
session_id: capture.session_id,
@@ -722,7 +808,11 @@ fn capture_output_item(event: &ProviderEvent, capture: &mut GrokSessionCapture)
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if event_type == Some("response.output_item.done")
&& capture.output_items.len() < XAI_SESSION_OUTPUT_LIMIT
&& let Some(item) = wire.data().get("item").cloned()
&& let Some(item) = wire
.data()
.get("item")
.cloned()
.and_then(|item| portable_output_item(item, false))
{
let index = wire
.data()
@@ -743,16 +833,17 @@ fn capture_output_item(event: &ProviderEvent, capture: &mut GrokSessionCapture)
.and_then(Value::as_array)
.filter(|output| !output.is_empty())
{
capture.output_items.clear();
capture.output_items.extend(
output
.iter()
.take(XAI_SESSION_OUTPUT_LIMIT)
.enumerate()
.filter_map(|(index, item)| {
u32::try_from(index).ok().map(|index| (index, item.clone()))
}),
);
for (index, item) in output.iter().take(XAI_SESSION_OUTPUT_LIMIT).enumerate() {
let Some(index) = u32::try_from(index).ok() else {
break;
};
if capture.output_items.contains_key(&index) {
continue;
}
if let Some(item) = portable_output_item(item.clone(), false) {
capture.output_items.insert(index, item);
}
}
}
}
@@ -862,17 +953,11 @@ async fn start_grok_inference(
return Err(GrokInferenceStartFailure { observation, error });
}
};
let mut observation = ProviderResponseObservation::new(
UpstreamTransport::new(HTTP_SSE_TRANSPORT).map_err(|_| GrokInferenceStartFailure {
let observation =
xai_response_observation(&response).map_err(|error| GrokInferenceStartFailure {
observation: None,
error: protocol_sent(),
})?,
)
.with_http_version(response.http_version())
.with_status_code(response.status_code());
if let Some(request_id) = response.request_id().cloned() {
observation = observation.with_request_id(request_id);
}
error,
})?;
Ok(AcceptedGrokInference {
response,
observation,
@@ -1189,17 +1274,7 @@ fn cold_http_sse_stream(
}
};
let mut observation = ProviderResponseObservation::new(
UpstreamTransport::new(HTTP_SSE_TRANSPORT).map_err(|_| provider_error(
ProviderErrorKind::Protocol,
UpstreamSendState::Sent,
))?,
)
.with_http_version(response.http_version())
.with_status_code(response.status_code());
if let Some(request_id) = response.request_id().cloned() {
observation = observation.with_request_id(request_id);
}
let observation = xai_response_observation(&response)?;
yield ProviderEvent::observation(observation);
let mut body = response.into_body();
@@ -1295,7 +1370,68 @@ fn xai_error_observation(
if let Some(request_id) = error.request_id().cloned() {
observation = observation.with_request_id(request_id);
}
Ok(observation)
Ok(with_xai_transport_metrics(
observation,
error.transport_metrics(),
))
}
fn xai_response_observation(
response: &GrokInferenceResponse,
) -> Result<ProviderResponseObservation, ProviderError> {
let mut observation = ProviderResponseObservation::new(
UpstreamTransport::new(HTTP_SSE_TRANSPORT)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent))?,
)
.with_http_version(response.http_version())
.with_status_code(response.status_code());
if let Some(request_id) = response.request_id().cloned() {
observation = observation.with_request_id(request_id);
}
Ok(with_xai_transport_metrics(
observation,
response.transport_metrics(),
))
}
fn with_xai_transport_metrics(
mut observation: ProviderResponseObservation,
metrics: GrokInferenceTransportMetrics,
) -> ProviderResponseObservation {
observation = observation.with_timings(ProviderResponseTimings {
headers_ms: metrics.headers_ms(),
..ProviderResponseTimings::default()
});
if let Some(metadata) = xai_transport_metadata(metrics) {
observation = observation.with_provider_metadata(metadata);
}
observation
}
fn xai_transport_metadata(
metrics: GrokInferenceTransportMetrics,
) -> Option<ProviderResponseMetadata> {
let mut metadata = Map::new();
if let Some(status) = metrics.client_cache_status() {
metadata.insert(
"clientCache".to_owned(),
Value::String(status.as_str().to_owned()),
);
}
if let Some(dns) = metrics.dns() {
metadata.insert(
"dnsSource".to_owned(),
Value::String(dns.source().as_str().to_owned()),
);
metadata.insert("dnsMs".to_owned(), Value::from(dns.duration_ms()));
}
if let Some(headers_ms) = metrics.headers_ms() {
metadata.insert("upstreamHeadersMs".to_owned(), Value::from(headers_ms));
}
if metadata.is_empty() {
return None;
}
ProviderResponseMetadata::new(serde_json::to_string(&Value::Object(metadata)).ok()?)
}
async fn record_failure(
@@ -1351,8 +1487,10 @@ async fn recover_or_record_failure(
.await
{
GrokCredentialRecoveryOutcome::Recovered => error.with_same_account_retry(),
GrokCredentialRecoveryOutcome::Rejected
| GrokCredentialRecoveryOutcome::Unavailable => error,
GrokCredentialRecoveryOutcome::Rejected => error,
GrokCredentialRecoveryOutcome::Unavailable => {
record_failure(selector, session, error).await
}
};
}
record_failure(selector, session, error).await
@@ -1415,10 +1553,23 @@ fn ensure_sent_context(context: &AttemptContext) -> Result<(), ProviderError> {
fn map_request_error(error: GrokRequestEncodeError) -> ProviderError {
let kind = match error {
GrokRequestEncodeError::InvalidProtocolPayload
| GrokRequestEncodeError::InvalidRequestNormalization => ProviderErrorKind::InvalidRequest,
| GrokRequestEncodeError::InvalidRequestNormalization
| GrokRequestEncodeError::InvalidRequestField { .. } => ProviderErrorKind::InvalidRequest,
GrokRequestEncodeError::Serialization => ProviderErrorKind::Protocol,
};
provider_error(kind, UpstreamSendState::NotSent)
let provider_error = provider_error(kind, UpstreamSendState::NotSent);
if kind != ProviderErrorKind::InvalidRequest {
return provider_error;
}
let detail = ClientVisibleUpstreamError::new(
error.to_string(),
Some("invalid_request_normalization".to_owned()),
Some("invalid_request_error".to_owned()),
);
match detail {
Ok(detail) => provider_error.with_client_visible_upstream_error(detail),
Err(_) => provider_error,
}
}
fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
@@ -49,6 +49,8 @@ pub use session::{
GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture, SelectedGrokSession,
};
pub use transport::{
GrokInferenceChunkStream, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceChunkStream, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokInferenceTransportMetrics,
};
@@ -4,8 +4,8 @@ use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use futures::StreamExt;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
@@ -20,13 +20,14 @@ use crate::credential::discovery::MAX_OAUTH_RESPONSE_BYTES;
use crate::{
GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
GrokInferenceClientCacheStatus, GrokInferenceDnsObservation, GrokInferenceDnsSource,
GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, GrokSessionBinding, HttpMethod, MAX_GROK_BILLING_BYTES,
MAX_GROK_MODEL_CATALOG_BYTES, OAuthHttpRequest, OAuthHttpResponse, OAuthHttpTransport,
TransportFailure, TransportFailureKind, TransportFuture,
GrokInferenceTransportMetrics, GrokModelCatalogRequest, GrokModelCatalogTransport,
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokSessionBinding,
HttpMethod, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OAuthHttpRequest,
OAuthHttpResponse, OAuthHttpTransport, TransportFailure, TransportFailureKind, TransportFuture,
};
use gateway_core::engine::UpstreamSendState;
use gateway_core::error::SafeUpstreamValue;
@@ -42,6 +43,9 @@ pub(crate) const OFFICIAL_USERINFO_PATH: &str = "/oauth2/userinfo";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const OAUTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const POOL_MAX_IDLE_PER_HOST: usize = 2;
const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15);
const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
const MAX_INFERENCE_BODY_BYTES: usize = 256 * 1024 * 1024;
const MAX_RETRY_AFTER_SECONDS: u64 = 120;
@@ -55,6 +59,10 @@ const TRUSTED_DOH_BOOTSTRAP: [SocketAddr; 2] = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 4, 4)), 443),
];
tokio::task_local! {
static REQUEST_DNS_OBSERVER: Arc<RequestDnsObserver>;
}
/// 构建严格 reqwest transport 失败。
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum GrokReqwestTransportBuildError {
@@ -314,12 +322,31 @@ impl ReqwestGrokInferenceTransport {
fn client_for(
&self,
binding: &GrokSessionBinding,
) -> Result<Client, GrokInferenceTransportError> {
self.clients
) -> Result<(Client, GrokInferenceClientCacheStatus), GrokInferenceTransportError> {
let unbound_client = {
let mut clients = self
.clients
.lock()
.map_err(|_| inference_client_pool_unavailable())?;
if let Some(client) = clients.get(binding) {
return Ok((client, GrokInferenceClientCacheStatus::Hit));
}
clients.take_unbound()
};
let client = match unbound_client {
Some(client) => client,
None => self
.endpoint_policy
.build_inference_client(None)
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?,
};
let client = self
.clients
.lock()
.map_err(|_| inference_client_pool_unavailable())?
.get_or_insert(binding, self.endpoint_policy.as_ref())
.map_err(|_| inference_client_pool_unavailable())
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?
.insert_if_absent(binding, client);
Ok((client, GrokInferenceClientCacheStatus::Miss))
}
}
@@ -342,53 +369,62 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
UpstreamSendState::NotSent,
));
}
let client = self.client_for(request.binding())?;
let (client, client_cache_status) = self.client_for(request.binding())?;
let mut builder = client
.post(request.endpoint().clone())
.body(request.body().to_vec());
for header in request.headers() {
builder = builder.header(header.name(), header.value().expose());
}
let response = builder
.send()
let dns_observer = Arc::new(RequestDnsObserver::default());
let headers_started_at = Instant::now();
let response = REQUEST_DNS_OBSERVER
.scope(Arc::clone(&dns_observer), builder.send())
.await
.map_err(classify_inference_reqwest_error)?;
.map_err(|error| {
classify_inference_reqwest_error(error).with_transport_metrics(
inference_transport_metrics(client_cache_status, None, &dns_observer),
)
})?;
let transport_metrics = inference_transport_metrics(
client_cache_status,
Some(elapsed_millis(headers_started_at.elapsed())),
&dns_observer,
);
if !response.status().is_success() {
return Err(classify_inference_status(response).await);
}
if !is_event_stream(&response) {
return Err(GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Protocol,
UpstreamSendState::Sent,
));
return Err(classify_inference_status(response)
.await
.with_transport_metrics(transport_metrics));
}
let http_version = upstream_http_version(response.version());
let status_code = response.status().as_u16();
let request_id = upstream_request_id(&response);
let body = response.bytes_stream().scan(0_usize, |observed, chunk| {
let item = match chunk {
Ok(chunk)
if observed
.checked_add(chunk.len())
.is_some_and(|total| total <= MAX_INFERENCE_BODY_BYTES) =>
{
*observed += chunk.len();
Ok(chunk.to_vec())
}
Ok(_) => Err(GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Protocol,
UpstreamSendState::Sent,
)),
Err(error) => Err(classify_inference_stream_error(&error)),
};
std::future::ready(Some(item))
});
Ok(GrokInferenceResponse::new(
Box::pin(body),
http_version,
status_code,
request_id,
))
let body = response
.bytes_stream()
.scan(0_usize, move |observed, chunk| {
let item = match chunk {
Ok(chunk)
if observed
.checked_add(chunk.len())
.is_some_and(|total| total <= MAX_INFERENCE_BODY_BYTES) =>
{
*observed += chunk.len();
Ok(chunk.to_vec())
}
Ok(_) => Err(GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Protocol,
UpstreamSendState::Sent,
)
.with_transport_metrics(transport_metrics)),
Err(error) => Err(classify_inference_stream_error(&error)
.with_transport_metrics(transport_metrics)),
};
std::future::ready(Some(item))
});
Ok(
GrokInferenceResponse::new(Box::pin(body), http_version, status_code, request_id)
.with_transport_metrics(transport_metrics),
)
})
}
}
@@ -412,22 +448,24 @@ impl BoundInferenceClients {
}
}
fn get_or_insert(
&mut self,
binding: &GrokSessionBinding,
endpoint_policy: &dyn GrokEndpointPolicy,
) -> Result<Client, GrokReqwestTransportBuildError> {
fn get(&mut self, binding: &GrokSessionBinding) -> Option<Client> {
if let Some(client) = self.by_binding.get(binding).cloned() {
self.record_use(binding);
return Ok(client);
return Some(client);
}
None
}
let client = match self.unbound_client.take() {
Some(client) => client,
None => endpoint_policy.build_inference_client(None)?,
};
fn take_unbound(&mut self) -> Option<Client> {
self.unbound_client.take()
}
fn insert_if_absent(&mut self, binding: &GrokSessionBinding, client: Client) -> Client {
if let Some(existing) = self.get(binding) {
return existing;
}
self.insert(binding.clone(), client.clone());
Ok(client)
client
}
fn insert(&mut self, binding: GrokSessionBinding, client: Client) {
@@ -454,6 +492,29 @@ fn inference_client_pool_unavailable() -> GrokInferenceTransportError {
)
}
fn inference_client_pool_unavailable_for_cache_miss() -> GrokInferenceTransportError {
inference_client_pool_unavailable().with_transport_metrics(
GrokInferenceTransportMetrics::default()
.with_client_cache_status(GrokInferenceClientCacheStatus::Miss),
)
}
fn inference_transport_metrics(
client_cache_status: GrokInferenceClientCacheStatus,
headers_ms: Option<u64>,
dns_observer: &RequestDnsObserver,
) -> GrokInferenceTransportMetrics {
let mut metrics =
GrokInferenceTransportMetrics::default().with_client_cache_status(client_cache_status);
if let Some(headers_ms) = headers_ms {
metrics = metrics.with_headers_ms(headers_ms);
}
if let Some(dns) = dns_observer.observation() {
metrics = metrics.with_dns(dns);
}
metrics
}
/// 官方 Grok CLI proxy 模型目录 GET transport。
pub struct ReqwestGrokModelCatalogTransport {
client: Client,
@@ -587,8 +648,11 @@ fn build_official_client(
.no_proxy()
.connect_timeout(CONNECT_TIMEOUT)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.pool_max_idle_per_host(128)
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.http2_adaptive_window(true)
.http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL)
.http2_keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
.http2_keep_alive_while_idle(true)
.tcp_nodelay(true)
.https_only(true)
.dns_resolver(Arc::new(StrictDnsResolver::new(dns_policy)?));
@@ -632,6 +696,24 @@ struct StrictDnsResolver {
trusted_doh: TrustedDohResolver,
}
#[derive(Debug, Default)]
struct RequestDnsObserver {
observation: OnceLock<GrokInferenceDnsObservation>,
}
impl RequestDnsObserver {
fn record(&self, source: GrokInferenceDnsSource, duration: Duration) {
let _ = self.observation.set(GrokInferenceDnsObservation::new(
source,
elapsed_millis(duration),
));
}
fn observation(&self) -> Option<GrokInferenceDnsObservation> {
self.observation.get().copied()
}
}
impl StrictDnsResolver {
fn new(policy: GrokDnsResolutionPolicy) -> Result<Self, GrokReqwestTransportBuildError> {
Ok(Self {
@@ -653,7 +735,9 @@ impl Resolve for StrictDnsResolver {
}
let policy = self.policy;
let trusted_doh = self.trusted_doh.clone();
let observer = REQUEST_DNS_OBSERVER.try_with(Arc::clone).ok();
Box::pin(async move {
let started_at = Instant::now();
let system_addresses = match tokio::net::lookup_host((requested_host.as_str(), 0)).await
{
Ok(addresses) => addresses.collect::<Vec<_>>(),
@@ -663,29 +747,43 @@ impl Resolve for StrictDnsResolver {
.iter()
.map(SocketAddr::ip)
.collect::<Vec<_>>();
match policy
let (source, result) = match policy
.plan_system_resolution(&requested_host, &system_ips)
.map_err(|_| safe_dns_error("DNS resolution rejected"))?
{
GrokDnsResolutionPlan::System => {
Ok(Box::new(system_addresses.into_iter()) as Addrs)
}
GrokDnsResolutionPlan::System => (
GrokInferenceDnsSource::System,
Ok(Box::new(system_addresses.into_iter()) as Addrs),
),
GrokDnsResolutionPlan::TrustedDoh => {
let addresses = trusted_doh.resolve(&requested_host).await?;
policy
.validate_trusted_doh_resolution(&requested_host, &addresses)
.map_err(|_| safe_dns_error("trusted DNS result rejected"))?;
Ok(Box::new(
addresses
.into_iter()
.map(|address| SocketAddr::new(address, 0)),
) as Addrs)
let result = match trusted_doh.resolve(&requested_host).await {
Ok(addresses) => policy
.validate_trusted_doh_resolution(&requested_host, &addresses)
.map_err(|_| safe_dns_error("trusted DNS result rejected"))
.map(|()| {
Box::new(
addresses
.into_iter()
.map(|address| SocketAddr::new(address, 0)),
) as Addrs
}),
Err(error) => Err(error),
};
(GrokInferenceDnsSource::TrustedDoh, result)
}
};
if let Some(observer) = observer {
observer.record(source, started_at.elapsed());
}
result
})
}
}
fn elapsed_millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
#[derive(Clone)]
struct TrustedDohResolver {
client: Client,
@@ -1109,15 +1207,6 @@ fn retry_after(response: &Response) -> Option<Duration> {
.map(Duration::from_secs)
}
fn is_event_stream(response: &Response) -> bool {
response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("text/event-stream"))
}
fn is_json_response(response: &Response) -> bool {
response
.headers()
@@ -145,18 +145,82 @@ impl GrokCliReleaseTransport for OfficialGrokCliReleaseTransport {
pub struct GrokCliReleaseService {
profile: XaiWireProfileState,
transport: Arc<dyn GrokCliReleaseTransport>,
status: GrokCliReleaseStatus,
}
impl GrokCliReleaseService {
#[must_use]
pub fn new(profile: XaiWireProfileState, transport: Arc<dyn GrokCliReleaseTransport>) -> Self {
Self { profile, transport }
Self {
profile,
transport,
status: GrokCliReleaseStatus::default(),
}
}
#[must_use]
pub(crate) fn status(&self) -> GrokCliReleaseStatus {
self.status.clone()
}
pub async fn refresh(&self) -> Result<String, GrokCliReleaseError> {
let version = self.transport.fetch().await?;
self.profile.update_client_version(&version);
Ok(version)
let checked_at = Utc::now();
let result = self.transport.fetch().await;
match result {
Ok(version) => {
self.profile.update_client_version(&version);
self.status.record_success(checked_at, version.clone());
Ok(version)
}
Err(error) => {
self.status.record_failure(checked_at, &error);
Err(error)
}
}
}
}
/// 最近一次官方 Grok CLI 发布检查结果。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct GrokCliReleaseSnapshot {
pub(crate) checked_at: Option<DateTime<Utc>>,
pub(crate) latest_version: Option<String>,
pub(crate) last_error: Option<String>,
}
/// Provider 内共享的 Grok CLI 发布检查观察状态。
#[derive(Debug, Clone, Default)]
pub(crate) struct GrokCliReleaseStatus {
snapshot: Arc<RwLock<GrokCliReleaseSnapshot>>,
}
impl GrokCliReleaseStatus {
#[must_use]
pub(crate) fn snapshot(&self) -> GrokCliReleaseSnapshot {
self.snapshot
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn record_success(&self, checked_at: DateTime<Utc>, latest_version: String) {
*self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = GrokCliReleaseSnapshot {
checked_at: Some(checked_at),
latest_version: Some(latest_version),
last_error: None,
};
}
fn record_failure(&self, checked_at: DateTime<Utc>, error: &GrokCliReleaseError) {
let mut snapshot = self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
snapshot.checked_at = Some(checked_at);
snapshot.last_error = Some(error.to_string());
}
}
@@ -191,3 +255,91 @@ pub enum GrokCliReleaseError {
#[error("Grok CLI release version is invalid")]
InvalidVersion,
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Mutex;
use super::*;
struct ReleaseTransport {
outcomes: Mutex<VecDeque<Result<String, GrokCliReleaseError>>>,
}
impl ReleaseTransport {
fn new(outcomes: impl IntoIterator<Item = Result<String, GrokCliReleaseError>>) -> Self {
Self {
outcomes: Mutex::new(outcomes.into_iter().collect()),
}
}
}
impl GrokCliReleaseTransport for ReleaseTransport {
fn fetch(&self) -> BoxFuture<'_, Result<String, GrokCliReleaseError>> {
let outcome = self
.outcomes
.lock()
.expect("release outcomes lock")
.pop_front()
.expect("release outcome");
Box::pin(async move { outcome })
}
}
#[tokio::test]
async fn release_refresh_should_update_profile_and_observation() {
let profile = wire_profile();
let service = GrokCliReleaseService::new(
profile.clone(),
Arc::new(ReleaseTransport::new([Ok("0.2.112".to_owned())])),
);
let status = service.status();
assert_eq!(service.refresh().await.expect("release"), "0.2.112");
assert_eq!(profile.client_version(), "0.2.112");
let snapshot = status.snapshot();
assert!(snapshot.checked_at.is_some());
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
assert!(snapshot.last_error.is_none());
}
#[tokio::test]
async fn failed_release_refresh_should_keep_last_successful_version() {
let profile = wire_profile();
let service = GrokCliReleaseService::new(
profile.clone(),
Arc::new(ReleaseTransport::new([
Ok("0.2.112".to_owned()),
Err(GrokCliReleaseError::InvalidDocument),
])),
);
let status = service.status();
service.refresh().await.expect("first release");
assert!(matches!(
service.refresh().await,
Err(GrokCliReleaseError::InvalidDocument)
));
assert_eq!(profile.client_version(), "0.2.112");
let snapshot = status.snapshot();
assert!(snapshot.checked_at.is_some());
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
assert_eq!(
snapshot.last_error.as_deref(),
Some("Grok CLI release document is invalid")
);
}
fn wire_profile() -> XaiWireProfileState {
XaiWireProfileState::new(XaiWireProfileConfig {
client_identifier: "grok-shell".to_owned(),
client_version: "0.2.106".to_owned(),
client_mode: "headless".to_owned(),
target_os: "linux".to_owned(),
target_arch: "x86_64".to_owned(),
verified_at: Utc::now(),
})
}
}
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use gateway_core::operation::GenerateRequest;
@@ -279,6 +279,18 @@ pub enum GrokRequestEncodeError {
/// Responses compatibility fields could not be normalized safely.
#[error("Grok Build request normalization failed")]
InvalidRequestNormalization,
/// 请求中的具体字段无法安全转换为 Grok Build 接受的形态。
#[error("Grok Build request field `{field}` could not be normalized safely")]
InvalidRequestField { field: &'static str },
}
impl GrokRequestEncodeError {
fn at_field(self, field: &'static str) -> Self {
match self {
Self::InvalidRequestNormalization => Self::InvalidRequestField { field },
error => error,
}
}
}
fn sanitize_account_identity(body: &mut Map<String, Value>) {
@@ -548,11 +560,12 @@ fn normalize_responses_request(
}
let text = text
.as_object_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "text" })?;
if text.get("format").is_none_or(Value::is_null) {
text.insert(
"format".to_owned(),
normalize_response_format(response_format)?,
normalize_response_format(response_format)
.map_err(|error| error.at_field("response_format"))?,
);
}
}
@@ -711,20 +724,22 @@ impl GrokResponseTransform {
.stream_calls
.get_mut(&primary)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
if matches!(
state.identity.kind,
ToolKind::ToolSearch | ToolKind::Custom | ToolKind::ApplyPatch
) {
state.arguments.push_str(
value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default(),
);
if state.identity.kind == ToolKind::Custom {
state.last_delta = value.as_object().cloned();
match state.identity.kind {
ToolKind::Custom => {
state.arguments.push_str(
value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default(),
);
state.last_delta = value.as_object().cloned().map(|mut payload| {
payload.remove("delta");
payload
});
return Ok(Vec::new());
}
return Ok(Vec::new());
ToolKind::ToolSearch | ToolKind::ApplyPatch => return Ok(Vec::new()),
ToolKind::Function => {}
}
}
@@ -767,6 +782,10 @@ impl GrokResponseTransform {
&input,
),
});
if let Some(state) = self.stream_calls.get_mut(&primary) {
state.arguments.clear();
state.last_delta = None;
}
return Ok(output);
}
ToolKind::Function => {}
@@ -783,10 +802,17 @@ impl GrokResponseTransform {
return self.rewrite_apply_patch_done_event(value);
}
let completed_call = (event_type == "response.output_item.done")
.then(|| value.get("item").and_then(Value::as_object))
.flatten()
.and_then(|item| self.primary_stream_call(item));
self.rewrite_response_value(&mut value)?;
if let Some(response) = value.get_mut("response").and_then(Value::as_object_mut) {
self.restore_visible_tools(response);
}
if let Some(primary) = completed_call {
self.take_stream_call(&primary);
}
Ok(vec![GrokTransformedWireEvent {
event_type: event_type.to_owned(),
value,
@@ -847,6 +873,18 @@ impl GrokResponseTransform {
Some(primary)
}
fn primary_stream_call(&self, item: &Map<String, Value>) -> Option<String> {
["id", "call_id"]
.into_iter()
.filter_map(|field| item.get(field).and_then(Value::as_str))
.find_map(|key| self.stream_keys.get(key).cloned())
}
fn take_stream_call(&mut self, primary: &str) -> Option<StreamCallState> {
self.stream_keys.retain(|_, owner| owner != primary);
self.stream_calls.remove(primary)
}
fn rewrite_response_value(&self, value: &mut Value) -> Result<(), GrokRequestEncodeError> {
match value {
Value::Array(values) => {
@@ -953,15 +991,11 @@ impl GrokResponseTransform {
.and_then(Value::as_object)
.cloned()
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
let primary = ["id", "call_id"]
.into_iter()
.find_map(|field| original_item.get(field).and_then(Value::as_str))
.and_then(|key| self.stream_keys.get(key))
.cloned();
let mut added = primary
.as_ref()
.and_then(|key| self.stream_calls.get(key))
.and_then(|state| state.added_payload.clone())
let state = self
.primary_stream_call(&original_item)
.and_then(|primary| self.take_stream_call(&primary));
let mut added = state
.and_then(|state| state.added_payload)
.unwrap_or_else(|| {
Map::from_iter([(
"type".to_owned(),
@@ -1148,20 +1182,27 @@ impl ToolNormalizer {
mut self,
payload: &mut Map<String, Value>,
) -> Result<GrokResponseTransform, GrokRequestEncodeError> {
let (tools, had_tools) = optional_array(payload.get("tools"))?;
let (tools, had_tools) =
optional_array(payload.get("tools")).map_err(|error| error.at_field("tools"))?;
if had_tools {
self.response.visible_tools.clone_from(&tools);
}
let client_search = inspect_tool_search(&tools)?;
self.normalize_client_search_parallel(payload, client_search)?;
let client_search = inspect_tool_search(&tools).map_err(|error| error.at_field("tools"))?;
self.normalize_client_search_parallel(payload, client_search)
.map_err(|error| error.at_field("parallel_tool_calls"))?;
let mut normalized_tools = Vec::with_capacity(tools.len());
for raw_tool in &tools {
normalized_tools.extend(self.normalize_tool(raw_tool, "", client_search, false)?);
normalized_tools.extend(
self.normalize_tool(raw_tool, "", client_search, false)
.map_err(|error| error.at_field("tools"))?,
);
}
if let Some(Value::Array(items)) = payload.get("input") {
let normalized = self.normalize_input_items(items)?;
let normalized = self
.normalize_input_items(items)
.map_err(|error| error.at_field("input"))?;
normalized_tools.extend(normalized.loaded_tools);
self.response.visible_tools.extend(normalized.visible_tools);
payload.insert("input".to_owned(), Value::Array(normalized.items));
@@ -1169,11 +1210,14 @@ impl ToolNormalizer {
.get("input")
.is_some_and(|input| !input.is_null() && !input.is_string())
{
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
return Err(GrokRequestEncodeError::InvalidRequestField { field: "input" });
}
if self.client_search_tool.is_some() {
normalized_tools.push(Value::Object(self.build_client_search_function()?));
normalized_tools.push(Value::Object(
self.build_client_search_function()
.map_err(|error| error.at_field("tools"))?,
));
}
normalized_tools = dedupe_normalized_tools(normalized_tools);
if normalized_tools.is_empty() {
@@ -1184,7 +1228,8 @@ impl ToolNormalizer {
} else {
payload.insert("tools".to_owned(), Value::Array(normalized_tools.clone()));
}
self.normalize_tool_choice(payload, &normalized_tools)?;
self.normalize_tool_choice(payload, &normalized_tools)
.map_err(|error| error.at_field("tool_choice"))?;
Ok(self.response)
}
@@ -1299,6 +1344,11 @@ impl ToolNormalizer {
return Ok(Vec::new());
}
let mut converted = without_defer_loading(tool);
if let Some(parameters) = converted.get("parameters").cloned()
&& let Some(normalized) = normalize_function_parameters_root(&parameters)?
{
converted.insert("parameters".to_owned(), normalized);
}
let alias = self.alias(ToolIdentity::new(ToolKind::Function, namespace, name));
converted.insert("name".to_owned(), Value::String(alias));
Ok(vec![Value::Object(converted)])
@@ -1448,6 +1498,136 @@ fn optional_array(value: Option<&Value>) -> Result<(Vec<Value>, bool), GrokReque
}
}
/// Grok Build 要求 function parameters 的根节点必为 object;Codex 会为可选参数
/// 生成 `object | null`,这里只移除根节点 nullability,嵌套字段保持原样。
fn normalize_function_parameters_root(
value: &Value,
) -> Result<Option<Value>, GrokRequestEncodeError> {
let Some(schema) = value.as_object() else {
return Ok(None);
};
let mut normalized = schema.clone();
let mut changed = false;
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
if removed_null {
let remaining = types
.into_iter()
.filter(|value| value.as_str() != Some("null"))
.collect::<Vec<_>>();
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
return Err(invalid_function_parameters_root());
}
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
changed = true;
}
}
for keyword in ["anyOf", "oneOf"] {
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
continue;
};
let removed_null = branches.iter().any(is_null_only_schema);
if !removed_null {
continue;
}
let remaining = branches
.into_iter()
.filter(|branch| !is_null_only_schema(branch))
.collect::<Vec<_>>();
if remaining.is_empty()
|| remaining.iter().any(|branch| {
branch.as_object().is_none_or(|branch| {
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
})
})
{
return Err(invalid_function_parameters_root());
}
if remaining.len() == 1 && normalized.len() == 1 {
normalized = remaining[0]
.as_object()
.cloned()
.ok_or_else(invalid_function_parameters_root)?;
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
} else {
normalized.insert(keyword.to_owned(), Value::Array(remaining));
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
}
changed = true;
}
Ok(changed.then_some(Value::Object(normalized)))
}
fn is_null_only_schema(value: &Value) -> bool {
let Some(schema) = value.as_object() else {
return false;
};
match schema.get("type") {
Some(Value::String(kind)) => kind == "null",
Some(Value::Array(types)) if !types.is_empty() => {
types.iter().all(|kind| kind.as_str() == Some("null"))
}
_ => false,
}
}
fn is_object_root_schema(
schema: &Map<String, Value>,
root: &Map<String, Value>,
visited: &mut BTreeSet<String>,
) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => return kind == "object",
Some(Value::Array(types)) if !types.is_empty() => {
return types.iter().all(|kind| kind.as_str() == Some("object"));
}
Some(_) => return false,
None => {}
}
if schema.contains_key("properties") {
return true;
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return false;
};
if !visited.insert(reference.to_owned()) {
return false;
}
resolve_local_schema_ref(root, reference)
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
}
fn resolve_local_schema_ref<'a>(
root: &'a Map<String, Value>,
reference: &str,
) -> Option<&'a Map<String, Value>> {
if reference == "#" {
return Some(root);
}
let path = reference.strip_prefix("#/")?;
let mut segments = path.split('/');
let first = decode_json_pointer_segment(segments.next()?);
let mut current = root.get(&first)?;
for segment in segments {
let segment = decode_json_pointer_segment(segment);
current = current.as_object()?.get(&segment)?;
}
current.as_object()
}
fn decode_json_pointer_segment(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters",
}
}
fn inspect_tool_search(tools: &[Value]) -> Result<bool, GrokRequestEncodeError> {
let mut client_search = false;
let mut server_search = false;
@@ -79,6 +79,117 @@ impl fmt::Debug for GrokInferenceRequest {
pub type GrokInferenceChunkStream =
Pin<Box<dyn Stream<Item = Result<Vec<u8>, GrokInferenceTransportError>> + Send + 'static>>;
/// Whether the account-isolated inference client was already cached.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokInferenceClientCacheStatus {
/// The binding reused its existing account-isolated client.
Hit,
/// The binding was absent when the request first checked the cache.
Miss,
}
impl GrokInferenceClientCacheStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Hit => "hit",
Self::Miss => "miss",
}
}
}
/// Resolver that supplied the addresses used by the request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokInferenceDnsSource {
/// The system resolver returned an entirely public address set.
System,
/// The system result was unusable and the trusted DoH fallback supplied addresses.
TrustedDoh,
}
impl GrokInferenceDnsSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::TrustedDoh => "trusted_doh",
}
}
}
/// DNS work observed while opening an upstream connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GrokInferenceDnsObservation {
source: GrokInferenceDnsSource,
duration_ms: u64,
}
impl GrokInferenceDnsObservation {
#[must_use]
pub const fn new(source: GrokInferenceDnsSource, duration_ms: u64) -> Self {
Self {
source,
duration_ms,
}
}
#[must_use]
pub const fn source(self) -> GrokInferenceDnsSource {
self.source
}
#[must_use]
pub const fn duration_ms(self) -> u64 {
self.duration_ms
}
}
/// Low-cardinality transport timings and pool facts for one inference request.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GrokInferenceTransportMetrics {
headers_ms: Option<u64>,
client_cache_status: Option<GrokInferenceClientCacheStatus>,
dns: Option<GrokInferenceDnsObservation>,
}
impl GrokInferenceTransportMetrics {
#[must_use]
pub const fn with_headers_ms(mut self, headers_ms: u64) -> Self {
self.headers_ms = Some(headers_ms);
self
}
#[must_use]
pub const fn with_client_cache_status(
mut self,
status: GrokInferenceClientCacheStatus,
) -> Self {
self.client_cache_status = Some(status);
self
}
#[must_use]
pub const fn with_dns(mut self, dns: GrokInferenceDnsObservation) -> Self {
self.dns = Some(dns);
self
}
#[must_use]
pub const fn headers_ms(self) -> Option<u64> {
self.headers_ms
}
#[must_use]
pub const fn client_cache_status(self) -> Option<GrokInferenceClientCacheStatus> {
self.client_cache_status
}
#[must_use]
pub const fn dns(self) -> Option<GrokInferenceDnsObservation> {
self.dns
}
}
/// Accepted inference response. Non-success HTTP responses must be returned as
/// [`GrokInferenceTransportError`] instead.
pub struct GrokInferenceResponse {
@@ -86,6 +197,7 @@ pub struct GrokInferenceResponse {
http_version: UpstreamHttpVersion,
status_code: u16,
request_id: Option<SafeUpstreamValue>,
transport_metrics: GrokInferenceTransportMetrics,
}
impl GrokInferenceResponse {
@@ -102,9 +214,19 @@ impl GrokInferenceResponse {
http_version,
status_code,
request_id,
transport_metrics: GrokInferenceTransportMetrics::default(),
}
}
#[must_use]
pub const fn with_transport_metrics(
mut self,
transport_metrics: GrokInferenceTransportMetrics,
) -> Self {
self.transport_metrics = transport_metrics;
self
}
#[must_use]
pub const fn http_version(&self) -> UpstreamHttpVersion {
self.http_version
@@ -120,6 +242,11 @@ impl GrokInferenceResponse {
self.request_id.as_ref()
}
#[must_use]
pub const fn transport_metrics(&self) -> GrokInferenceTransportMetrics {
self.transport_metrics
}
pub fn into_body(self) -> GrokInferenceChunkStream {
self.body
}
@@ -132,6 +259,7 @@ impl fmt::Debug for GrokInferenceResponse {
.field("http_version", &self.http_version)
.field("status_code", &self.status_code)
.field("request_id", &self.request_id)
.field("transport_metrics", &self.transport_metrics)
.field("body", &"[SSE STREAM]")
.finish()
}
@@ -174,6 +302,7 @@ pub struct GrokInferenceTransportError {
http_version: Option<UpstreamHttpVersion>,
request_id: Option<SafeUpstreamValue>,
upstream_code: Option<SafeUpstreamValue>,
transport_metrics: GrokInferenceTransportMetrics,
credential_recovery_required: bool,
sensitive_context_redacted: bool,
}
@@ -190,6 +319,11 @@ impl GrokInferenceTransportError {
http_version: None,
request_id: None,
upstream_code: None,
transport_metrics: GrokInferenceTransportMetrics {
headers_ms: None,
client_cache_status: None,
dns: None,
},
credential_recovery_required: false,
sensitive_context_redacted: false,
}
@@ -229,6 +363,15 @@ impl GrokInferenceTransportError {
self
}
#[must_use]
pub const fn with_transport_metrics(
mut self,
transport_metrics: GrokInferenceTransportMetrics,
) -> Self {
self.transport_metrics = transport_metrics;
self
}
#[must_use]
pub const fn with_credential_recovery(mut self) -> Self {
self.credential_recovery_required = true;
@@ -281,6 +424,11 @@ impl GrokInferenceTransportError {
self.upstream_code.as_ref()
}
#[must_use]
pub const fn transport_metrics(&self) -> GrokInferenceTransportMetrics {
self.transport_metrics
}
#[must_use]
pub const fn requires_credential_recovery(&self) -> bool {
self.credential_recovery_required
@@ -304,6 +452,7 @@ impl fmt::Debug for GrokInferenceTransportError {
.field("http_version", &self.http_version)
.field("request_id", &self.request_id)
.field("upstream_code", &self.upstream_code)
.field("transport_metrics", &self.transport_metrics)
.field(
"credential_recovery_required",
&self.credential_recovery_required,
+6 -2
View File
@@ -9,7 +9,9 @@ use futures::{StreamExt as _, future::BoxFuture};
use gateway_admin::model::accounts::{
AccountAvailability as AdminAccountAvailability, AccountRecord,
};
use gateway_admin::model::observability::{CurrencyCost, ProviderBillingInput};
use gateway_admin::model::observability::{
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
};
use gateway_admin::model::provider_credentials::{
CompleteAuthorization, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
@@ -281,7 +283,9 @@ async fn xai_admin_provider_validates_known_billing_breakdown() {
assert_eq!(profile.product, "Grok Build");
assert_eq!(profile.version, "0.2.106");
assert_eq!(profile.user_agent, "grok-shell/0.2.106 (linux; x86_64)");
assert!(profile.release.is_none());
let release = profile.release.expect("release status");
assert_eq!(release.status, DesktopReleaseStatus::Unchecked);
assert!(release.checked_at.is_none());
let billing = admin
.calculated_billing(&ProviderBillingInput {
@@ -20,8 +20,8 @@ use provider_xai::{
};
use crate::support::{
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object,
runtime_policy, seed_input,
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input,
credential_object, runtime_policy, seed_input,
};
const OFFICIAL_FIXTURE: &[u8] =
@@ -169,11 +169,13 @@ async fn fixture_many(
available: lease_available,
calls: AtomicUsize::new(0),
});
let cooldowns = Arc::new(MemoryCooldownPort::default());
let service = GrokCredentialRefreshService::new(
repository.clone(),
refresher_port,
catalog,
leases,
cooldowns,
runtime_policy(),
);
(store, repository, refresher, service)
@@ -380,10 +382,11 @@ async fn banned_failure_marks_account_banned() {
}
#[tokio::test]
async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
async fn ambiguous_refresh_uses_runtime_cooldown_without_persisting_account_state() {
let input = due_input("ambiguous");
let id = input.account_id.clone();
let (store, _, _, service) = fixture(input, [Err(GrokRefreshFailure::Ambiguous)], true).await;
let (store, _, refresher, service) =
fixture(input, [Err(GrokRefreshFailure::Ambiguous)], true).await;
let outcomes = service.refresh_due().await.expect("refresh");
assert!(matches!(
outcomes.as_slice(),
@@ -391,8 +394,9 @@ async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
));
assert_eq!(
store.account(&id).expect("account").availability(),
AccountAvailability::Invalid
AccountAvailability::Unknown
);
assert_eq!(store.account(&id).expect("account").revision().get(), 1);
assert!(
service
.refresh_due()
@@ -400,6 +404,7 @@ async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
.expect("second cycle")
.is_empty()
);
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
@@ -23,7 +23,8 @@ use provider_xai::{
};
use crate::support::{
MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id, create_input, seed_input,
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id,
create_input, seed_input,
};
struct SchedulingCoordinator {
@@ -88,6 +89,7 @@ struct SelectorFixture {
cache: Arc<MemoryGrokCatalogCache>,
selector: GrokAccountSessionSelector,
coordinator: Arc<SchedulingCoordinator>,
cooldowns: Arc<MemoryCooldownPort>,
feedback: Arc<AccountFeedbackStats>,
}
@@ -137,6 +139,7 @@ impl SelectorFixture {
signals: Mutex::new(signals),
denied: Mutex::new(BTreeSet::new()),
});
let cooldowns = Arc::new(MemoryCooldownPort::default());
let catalog_cache: Arc<dyn GrokCredentialCatalogCache> = cache.clone();
let lease_port: Arc<dyn ProviderLeasePort> = coordinator.clone();
let quota = Arc::new(crate::support::grok_quota_service(
@@ -150,6 +153,7 @@ impl SelectorFixture {
catalog_cache,
quota,
lease_port,
cooldowns.clone(),
Arc::clone(&feedback),
);
Self {
@@ -157,6 +161,7 @@ impl SelectorFixture {
cache,
selector,
coordinator,
cooldowns,
feedback,
}
}
@@ -257,7 +262,7 @@ async fn required_account_overrides_smart_selection_without_fallback() {
}
#[tokio::test]
async fn unauthorized_feedback_marks_only_selected_account_invalid() {
async fn unauthorized_feedback_records_runtime_cooldown_without_persisting_account_state() {
let fixture = SelectorFixture::new(&["feedback-a", "feedback-b"]).await;
let session = fixture
.selector
@@ -275,7 +280,7 @@ async fn unauthorized_feedback_marks_only_selected_account_invalid() {
.account(&selected)
.expect("selected")
.availability(),
AccountAvailability::Invalid
AccountAvailability::Ready
);
let other = [account_id("feedback-a"), account_id("feedback-b")]
.into_iter()
@@ -285,16 +290,78 @@ async fn unauthorized_feedback_marks_only_selected_account_invalid() {
fixture.store.account(&other).expect("other").availability(),
AccountAvailability::Ready
);
assert!(
fixture
.cooldowns
.cooldown(&selected)
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
);
}
#[tokio::test]
async fn rate_limit_feedback_applies_bounded_cooldown() {
let fixture = SelectorFixture::new(&["rate-limit"]).await;
async fn cooldown_for_superseded_credential_does_not_block_rotated_account() {
let fixture = SelectorFixture::new(&["revision-fence"]).await;
let id = account_id("revision-fence");
let session = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("session");
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::RateLimited {
retry_after: Some(Duration::from_secs(60)),
},
)
.await;
let current = fixture
.store
.load_credential(&id, CredentialRevision::new(1).expect("revision"))
.await
.expect("current credential");
let prepared = GrokCredentialAdmin
.prepare_rotation(&RotateManagedGrokCredential {
current,
secret: provider_xai::GrokOAuthSecret {
access_token: provider_xai::SecretValue::new("new-access"),
refresh_token: provider_xai::SecretValue::new("new-refresh"),
id_token: None,
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
},
verified_account: crate::support::profile("subject-revision-fence"),
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
})
.expect("rotate");
assert!(matches!(
fixture
.store
.compare_and_swap_credential(prepared.credential)
.await
.expect("persist rotation"),
CredentialCasOutcome::Updated(revision) if revision.get() == 2
));
let next = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("replacement credential should not inherit cooldown");
assert_eq!(next.account_id(), &id);
assert!(fixture.cooldowns.cooldown(&id).is_none());
}
#[tokio::test]
async fn rate_limit_feedback_records_runtime_cooldown_without_persisting_account_state() {
let fixture = SelectorFixture::new(&["rate-limit", "available"]).await;
let session = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("session");
let selected = session.account_id().clone();
fixture
.selector
.record_failure(
@@ -304,20 +371,24 @@ async fn rate_limit_feedback_applies_bounded_cooldown() {
},
)
.await;
let account = fixture
.store
.account(session.account_id())
.expect("account");
assert_eq!(account.availability(), AccountAvailability::Cooldown);
let account = fixture.store.account(&selected).expect("account");
assert_eq!(account.availability(), AccountAvailability::Ready);
assert!(
account
.cooldown_until()
.is_some_and(|until| until > SystemTime::now())
fixture
.cooldowns
.cooldown(&selected)
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
);
let next = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("runtime cooldown should leave the other account available");
assert_ne!(next.account_id(), &selected);
}
#[tokio::test]
async fn interrupted_stream_feedback_applies_account_cooldown() {
async fn interrupted_stream_feedback_records_runtime_cooldown_without_persisting_account_state() {
let fixture = SelectorFixture::new(&["stream-interrupted"]).await;
let session = fixture
.selector
@@ -333,12 +404,20 @@ async fn interrupted_stream_feedback_applies_account_cooldown() {
.store
.account(session.account_id())
.expect("account");
assert_eq!(account.availability(), AccountAvailability::Cooldown);
assert_eq!(account.availability(), AccountAvailability::Ready);
assert!(
account
.cooldown_until()
.is_some_and(|until| until > SystemTime::now())
fixture
.cooldowns
.cooldown(session.account_id())
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
);
assert!(matches!(
fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await,
Err(GrokSessionSelectorError::NoEligibleSession)
));
}
#[tokio::test]
@@ -31,12 +31,14 @@ use gateway_core::routing::{
use provider_xai::{
GrokBuildProvider, GrokCredentialCatalogCache, GrokCredentialFailure,
GrokCredentialFeedbackFuture, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
GrokCredentialRepository, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokCredentialRepository, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, GrokSessionBinding, GrokSessionSelection,
GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture, SecretValue,
SelectedGrokSession,
GrokInferenceTransportMetrics, GrokModelCatalogRequest, GrokModelCatalogTransport,
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokSessionBinding,
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
SecretValue, SelectedGrokSession,
};
use serde_json::{Map, json};
@@ -47,6 +49,17 @@ use crate::support::{
const MODEL: &str = "grok-4.5";
const CATALOG_FIXTURE: &[u8] =
include_bytes!("../transport/catalog/fixtures/official_grok_models_snapshot.json");
fn observed_transport_metrics() -> GrokInferenceTransportMetrics {
GrokInferenceTransportMetrics::default()
.with_headers_ms(42)
.with_client_cache_status(GrokInferenceClientCacheStatus::Miss)
.with_dns(GrokInferenceDnsObservation::new(
GrokInferenceDnsSource::System,
7,
))
}
const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
"object": "list",
"data": [{
@@ -245,6 +258,7 @@ impl GrokSessionSelector for StubSelector {
enum InferenceMode {
Success,
SuccessWithMetrics(GrokInferenceTransportMetrics),
SuccessBody(Vec<u8>),
Error(GrokInferenceTransportError),
StreamError(GrokInferenceTransportError),
@@ -265,6 +279,14 @@ impl StubInferenceTransport {
})
}
fn success_with_metrics(metrics: GrokInferenceTransportMetrics) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
requests: Mutex::new(Vec::new()),
modes: Mutex::new(VecDeque::from([InferenceMode::SuccessWithMetrics(metrics)])),
})
}
fn error(error: GrokInferenceTransportError) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
@@ -308,6 +330,13 @@ impl GrokInferenceTransport for StubInferenceTransport {
200,
None,
)),
InferenceMode::SuccessWithMetrics(metrics) => Ok(GrokInferenceResponse::new(
Box::pin(stream::iter([Ok(SUCCESS_SSE.to_vec())])),
UpstreamHttpVersion::Http2,
200,
None,
)
.with_transport_metrics(metrics)),
InferenceMode::SuccessBody(body) => Ok(GrokInferenceResponse::new(
Box::pin(stream::iter([Ok(body)])),
UpstreamHttpVersion::Http2,
@@ -352,6 +381,18 @@ impl GrokModelCatalogTransport for CatalogWithoutToolMetadataTransport {
}
}
struct UnavailableCatalogTransport;
impl GrokModelCatalogTransport for UnavailableCatalogTransport {
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
Box::pin(async {
Err(GrokModelCatalogTransportError::new(
GrokModelCatalogTransportErrorKind::Unavailable,
))
})
}
}
struct StubRecovery {
calls: AtomicUsize,
outcome: GrokCredentialRecoveryOutcome,
@@ -506,6 +547,19 @@ fn operation_with_reasoning_effort(effort: &str) -> Operation {
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
}
fn operation_with_invalid_tools() -> Operation {
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("hello")),
("tools".to_owned(), json!({"not": "an array"})),
]),
)
.expect("OpenAI payload");
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
}
fn compaction_operation() -> Operation {
let payload = ProtocolPayload::json_object(
"openai",
@@ -693,7 +747,7 @@ async fn execute_forwards_required_account_to_selector() {
#[tokio::test]
async fn execute_returns_cold_stream_and_records_selected_account() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::success();
let transport = StubInferenceTransport::success_with_metrics(observed_transport_metrics());
let provider = provider(selector, transport.clone()).await;
let mut stream = provider
.execute(
@@ -718,6 +772,23 @@ async fn execute_returns_cold_stream_and_records_selected_account() {
assert_eq!(observation.transport().as_str(), "http_sse");
assert_eq!(observation.http_version(), Some(UpstreamHttpVersion::Http2));
assert_eq!(observation.status_code(), Some(200));
assert_eq!(observation.timings().headers_ms, Some(42));
let metadata: serde_json::Value = serde_json::from_str(
observation
.provider_metadata()
.expect("xAI transport metadata")
.as_json(),
)
.expect("valid metadata JSON");
assert_eq!(
metadata,
json!({
"clientCache": "miss",
"dnsSource": "system",
"dnsMs": 7,
"upstreamHeadersMs": 42
})
);
assert!(
events
.iter()
@@ -727,6 +798,48 @@ async fn execute_returns_cold_stream_and_records_selected_account() {
);
}
#[tokio::test]
async fn transport_error_observation_should_retain_available_metrics() {
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Unavailable,
UpstreamSendState::Sent,
)
.with_status(503)
.with_transport_metrics(observed_transport_metrics()),
);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("provider stream");
let event = stream
.next()
.await
.expect("observation event")
.expect("observation must succeed");
let observation = event.response_observation().expect("transport observation");
assert_eq!(observation.status_code(), Some(503));
assert_eq!(observation.timings().headers_ms, Some(42));
let metadata: serde_json::Value = serde_json::from_str(
observation
.provider_metadata()
.expect("xAI transport metadata")
.as_json(),
)
.expect("valid metadata JSON");
assert_eq!(metadata["clientCache"], "miss");
assert_eq!(metadata["dnsSource"], "system");
assert_eq!(metadata["dnsMs"], 7);
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
}
#[tokio::test]
async fn compaction_stream_should_emit_openai_wire_and_keep_only_accounting_canonical() {
let summary = valid_compaction_summary("validated summary");
@@ -1047,6 +1160,38 @@ async fn first_unauthorized_should_refresh_and_request_one_same_account_retry()
assert!(selector.feedback.lock().expect("feedback").is_empty());
}
#[tokio::test]
async fn unavailable_unauthorized_recovery_records_temporary_credential_feedback() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Unauthorized,
UpstreamSendState::Sent,
)
.with_status(401)
.with_credential_recovery(),
);
let recovery = StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable);
let provider = provider_with_recovery(selector.clone(), transport, recovery.clone()).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::Unauthorized);
assert!(!error.retries_same_account());
assert_eq!(recovery.calls.load(Ordering::SeqCst), 1);
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::Unauthorized]
);
}
#[tokio::test]
async fn explicit_http_429_marks_provider_error_replay_safe() {
let error = mapped_transport_error(
@@ -1628,6 +1773,37 @@ async fn provider_rejects_target_owned_by_other_provider() {
assert_eq!(selector.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn provider_should_expose_safe_request_normalization_field_before_selection() {
let selector = StubSelector::success();
let provider = provider(selector.clone(), StubInferenceTransport::success()).await;
let error = match provider
.execute(
provider_request_with_operation("xai", operation_with_invalid_tools()),
context(CancellationToken::new(), None),
)
.await
{
Ok(_) => panic!("invalid tools must fail"),
Err(error) => error,
};
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
assert_eq!(
error.client_visible_upstream_error().map(|detail| (
detail.message(),
detail.code(),
detail.error_type()
)),
Some((
"Grok Build request field `tools` could not be normalized safely",
Some("invalid_request_normalization"),
Some("invalid_request_error"),
)),
);
assert_eq!(selector.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn provider_compiles_realtime_catalog_capabilities() {
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
@@ -1645,6 +1821,41 @@ async fn provider_compiles_realtime_catalog_capabilities() {
))
.is_some()
);
let presentation = capabilities[0]
.presentation()
.expect("Grok model presentation");
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
assert_eq!(
presentation.supported_reasoning_efforts(),
["low", "medium", "high"]
);
assert_eq!(presentation.context_window_tokens(), Some(1_000_000));
assert!(presentation.agent_tools());
}
#[tokio::test]
async fn provider_publishes_default_codex_profile_when_catalog_is_unavailable() {
let provider = provider_with_catalog_transport(
StubSelector::success(),
StubInferenceTransport::success(),
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
Arc::new(UnavailableCatalogTransport),
)
.await;
let capabilities = provider
.query_model_capabilities()
.await
.expect("fallback capabilities");
assert_eq!(capabilities.len(), 1);
assert_eq!(capabilities[0].upstream_model().as_str(), MODEL);
let presentation = capabilities[0]
.presentation()
.expect("fallback Grok model presentation");
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
assert_eq!(presentation.context_window_tokens(), Some(500_000));
assert!(presentation.agent_tools());
assert!(presentation.parallel_tool_calls());
}
#[tokio::test]
+58 -3
View File
@@ -15,9 +15,9 @@ use gateway_core::engine::credential::{
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::provider_ports::{
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderSessionAffinityKey,
ProviderSessionAffinityPort, ProviderSessionExclusionPort, ProviderSessionExclusions,
ProviderStoreError,
ProviderCooldown, ProviderCooldownPort, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
ProviderSessionAffinityKey, ProviderSessionAffinityPort, ProviderSessionExclusionPort,
ProviderSessionExclusions, ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_xai::{
@@ -550,6 +550,17 @@ pub struct TestSessionAffinity;
pub struct TestSessionExclusions;
#[derive(Default)]
pub struct MemoryCooldownPort {
cooldowns: Mutex<BTreeMap<ProviderAccountId, ProviderCooldown>>,
}
impl MemoryCooldownPort {
pub fn cooldown(&self, account_id: &ProviderAccountId) -> Option<ProviderCooldown> {
lock(&self.cooldowns).get(account_id).cloned()
}
}
impl ProviderSessionAffinityPort for TestSessionAffinity {
fn load<'a>(
&'a self,
@@ -613,6 +624,50 @@ impl ProviderSessionExclusionPort for TestSessionExclusions {
}
}
impl ProviderCooldownPort for MemoryCooldownPort {
fn put_if_later(
&self,
cooldown: ProviderCooldown,
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let mut cooldowns = lock(&self.cooldowns);
let should_write = cooldowns.get(cooldown.account_id()).is_none_or(|current| {
current.credential_revision() < cooldown.credential_revision()
|| (current.credential_revision() == cooldown.credential_revision()
&& current.until() < cooldown.until())
});
if should_write {
cooldowns.insert(cooldown.account_id().clone(), cooldown);
}
Ok(should_write)
})
}
fn read<'a>(
&'a self,
account_id: &'a ProviderAccountId,
) -> futures::future::BoxFuture<'a, Result<Option<ProviderCooldown>, ProviderStoreError>> {
Box::pin(async move { Ok(self.cooldown(account_id)) })
}
fn clear<'a>(
&'a self,
account_id: &'a ProviderAccountId,
through_revision: CredentialRevision,
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let mut cooldowns = lock(&self.cooldowns);
let should_remove = cooldowns
.get(account_id)
.is_some_and(|current| current.credential_revision() <= through_revision);
if should_remove {
cooldowns.remove(account_id);
}
Ok(should_remove)
})
}
}
impl ProviderRuntimePolicyPort for StaticRuntimePolicy {
fn load_refresh_policy(
&self,
@@ -114,6 +114,42 @@ fn decoder_should_normalize_text_usage_and_completion() {
));
}
#[test]
fn decoder_should_emit_each_complete_text_event_without_waiting_for_the_stream_end() {
let mut decoder = GrokCanonicalDecoder::new("grok-4.5");
let created = decoder
.push(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_live\",\"model\":\"grok-4.5\"}}\n\n",
)
.as_bytes(),
)
.expect("created event");
let delta = decoder
.push(
concat!(
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"live\"}\n\n",
)
.as_bytes(),
)
.expect("text delta");
assert!(
created
.iter()
.filter_map(ProviderEvent::wire_event)
.any(|event| event.event_type() == Some("response.created"))
);
assert!(delta.iter().any(|event| {
event.wire_event().is_some_and(|wire| {
wire.event_type() == Some("response.output_text.delta")
&& wire.data().get("delta") == Some(&serde_json::json!("live"))
})
}));
}
#[test]
fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
let body = concat!(
@@ -416,6 +452,72 @@ fn decoder_should_restore_custom_apply_patch_arguments_as_raw_input() {
}));
}
#[test]
fn decoder_should_buffer_only_custom_arguments_until_the_done_event() {
let request = tool_request(serde_json::json!({
"model": "client",
"input": "render",
"tools": [{"type": "custom", "name": "render"}]
}));
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.5", &request);
decoder
.push(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_render\",\"model\":\"grok-4.5\"}}\n\n",
)
.as_bytes(),
)
.expect("custom response created");
let added = decoder
.push(
concat!(
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"item_render\",\"type\":\"function_call\",\"call_id\":\"call_render\",\"name\":\"render\"}}\n\n",
)
.as_bytes(),
)
.expect("custom item added");
let first_delta = decoder
.push(
concat!(
"event: response.function_call_arguments.delta\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"delta\":\"{\\\"input\\\":\\\"hel\"}\n\n",
)
.as_bytes(),
)
.expect("first custom argument delta");
let second_delta = decoder
.push(
concat!(
"event: response.function_call_arguments.delta\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"delta\":\"lo\\\"}\"}\n\n",
)
.as_bytes(),
)
.expect("second custom argument delta");
let done = decoder
.push(
concat!(
"event: response.function_call_arguments.done\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"arguments\":\"{\\\"input\\\":\\\"hello\\\"}\"}\n\n",
)
.as_bytes(),
)
.expect("custom argument done");
assert!(wire_events(&added).iter().any(|event| {
event.event_type() == Some("response.output_item.added")
&& event.data().pointer("/item/type") == Some(&serde_json::json!("custom_tool_call"))
}));
assert!(first_delta.is_empty());
assert!(second_delta.is_empty());
assert!(wire_events(&done).iter().any(|event| {
event.event_type() == Some("response.custom_tool_call_input.done")
&& event.data().get("input") == Some(&serde_json::json!("hello"))
}));
}
#[test]
fn decoder_should_coalesce_reasoning_item_part_and_summary_index() {
let body = concat!(
@@ -16,11 +16,12 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use provider_xai::{
FailClosedTokenVerifier, FormField, GrokBillingClient, GrokDnsResolutionPlan,
GrokDnsResolutionPolicy, GrokEndpointPolicy, GrokInferenceRequest, GrokInferenceTransport,
GrokInferenceTransportErrorKind, GrokModelCatalogSession, GrokOAuthClient, GrokOAuthConfig,
GrokReqwestTransportBuildError, GrokSessionBinding, HttpMethod, OAuthHttpRequest,
OAuthHttpTransport, OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport,
ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport, SecretValue,
GrokDnsResolutionPolicy, GrokEndpointPolicy, GrokInferenceClientCacheStatus,
GrokInferenceRequest, GrokInferenceTransport, GrokInferenceTransportErrorKind,
GrokModelCatalogSession, GrokOAuthClient, GrokOAuthConfig, GrokReqwestTransportBuildError,
GrokSessionBinding, HttpMethod, OAuthHttpRequest, OAuthHttpTransport,
OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport,
ReqwestOAuthTransport, SecretValue,
};
use crate::support::loopback_endpoint_policy;
@@ -89,6 +90,43 @@ async fn inference_transport_should_stream_one_official_shape_response() {
assert_eq!(chunks.len(), 1);
}
#[tokio::test]
async fn inference_transport_should_report_account_client_cache_miss_then_hit() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(
ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream"),
)
.expect(2)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let transport = inference_transport(&origin);
let first = transport
.execute(inference_request(&origin))
.await
.expect("first response");
let first_metrics = first.transport_metrics();
assert_eq!(
first_metrics.client_cache_status(),
Some(GrokInferenceClientCacheStatus::Miss)
);
assert!(first_metrics.headers_ms().is_some());
first.into_body().collect::<Vec<_>>().await;
let second = transport
.execute(inference_request(&origin))
.await
.expect("second response");
assert_eq!(
second.transport_metrics().client_cache_status(),
Some(GrokInferenceClientCacheStatus::Hit)
);
second.into_body().collect::<Vec<_>>().await;
}
#[tokio::test]
async fn inference_transport_should_reuse_one_client_only_within_the_same_binding() {
let server = MockServer::start().await;
@@ -105,6 +143,7 @@ async fn inference_transport_should_reuse_one_client_only_within_the_same_bindin
let endpoint_policy: Arc<dyn GrokEndpointPolicy> = Arc::new(CountingEndpointPolicy {
inner: loopback_endpoint_policy(&origin),
inference_client_builds: Arc::clone(&client_builds),
concurrency: None,
});
let transport = ReqwestGrokInferenceTransport::new(endpoint_policy).expect("transport");
@@ -132,6 +171,7 @@ async fn inference_transport_should_evict_the_least_recent_binding_at_the_fixed_
let endpoint_policy: Arc<dyn GrokEndpointPolicy> = Arc::new(CountingEndpointPolicy {
inner: loopback_endpoint_policy(&origin),
inference_client_builds: Arc::clone(&client_builds),
concurrency: None,
});
let transport = ReqwestGrokInferenceTransport::new(endpoint_policy).expect("transport");
@@ -156,6 +196,62 @@ async fn inference_transport_should_evict_the_least_recent_binding_at_the_fixed_
);
}
#[tokio::test]
async fn inference_transport_should_build_distinct_cold_account_clients_concurrently() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(
ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream"),
)
.expect(3)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let concurrency = Arc::new(BuildConcurrency::new(Duration::from_millis(100)));
let endpoint_policy: Arc<dyn GrokEndpointPolicy> = Arc::new(CountingEndpointPolicy {
inner: loopback_endpoint_policy(&origin),
inference_client_builds: Arc::new(AtomicUsize::new(0)),
concurrency: Some(Arc::clone(&concurrency)),
});
let transport = Arc::new(
ReqwestGrokInferenceTransport::new(endpoint_policy).expect("concurrent transport"),
);
execute_inference(&transport, &origin, "account-primer").await;
let start = Arc::new(std::sync::Barrier::new(2));
let first = {
let start = Arc::clone(&start);
let transport = Arc::clone(&transport);
let origin = origin.clone();
tokio::task::spawn_blocking(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("first request runtime");
start.wait();
runtime.block_on(execute_inference(&transport, &origin, "account-cold-a"));
})
};
let second = {
let start = Arc::clone(&start);
let transport = Arc::clone(&transport);
let origin = origin.clone();
tokio::task::spawn_blocking(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("second request runtime");
start.wait();
runtime.block_on(execute_inference(&transport, &origin, "account-cold-b"));
})
};
first.await.expect("first task");
second.await.expect("second task");
assert!(concurrency.max_active.load(Ordering::SeqCst) >= 2);
}
#[tokio::test]
async fn inference_transport_should_classify_http_failures_without_retaining_bodies() {
let cases = [
@@ -201,6 +297,11 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
Some(gateway_core::event::UpstreamHttpVersion::Http11),
)
);
assert_eq!(
error.transport_metrics().client_cache_status(),
Some(GrokInferenceClientCacheStatus::Miss)
);
assert!(error.transport_metrics().headers_ms().is_some());
assert!(!rendered.contains(&secret));
}
}
@@ -318,27 +419,28 @@ async fn inference_transport_should_classify_reasoning_decode_rejections() {
}
#[tokio::test]
async fn inference_transport_should_reject_success_with_non_sse_content_type() {
async fn inference_transport_should_not_reject_sse_bytes_only_because_content_type_is_wrong() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
"application/json",
))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
let chunks = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("successful non-SSE response must fail closed");
.expect("body syntax, rather than response metadata, determines the stream")
.into_body()
.collect::<Vec<_>>()
.await;
assert_eq!(
(error.kind(), error.send_state()),
(
GrokInferenceTransportErrorKind::Protocol,
UpstreamSendState::Sent,
)
);
assert_eq!(chunks.len(), 1);
assert!(chunks[0].is_ok());
}
#[tokio::test]
@@ -534,6 +636,24 @@ async fn execute_inference(transport: &ReqwestGrokInferenceTransport, origin: &U
struct CountingEndpointPolicy {
inner: Arc<dyn GrokEndpointPolicy>,
inference_client_builds: Arc<AtomicUsize>,
concurrency: Option<Arc<BuildConcurrency>>,
}
#[derive(Debug)]
struct BuildConcurrency {
delay: Duration,
active: AtomicUsize,
max_active: AtomicUsize,
}
impl BuildConcurrency {
fn new(delay: Duration) -> Self {
Self {
delay,
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
}
}
}
impl GrokEndpointPolicy for CountingEndpointPolicy {
@@ -549,7 +669,15 @@ impl GrokEndpointPolicy for CountingEndpointPolicy {
timeout: Option<Duration>,
) -> Result<Client, GrokReqwestTransportBuildError> {
self.inference_client_builds.fetch_add(1, Ordering::SeqCst);
self.inner.build_inference_client(timeout)
let Some(concurrency) = &self.concurrency else {
return self.inner.build_inference_client(timeout);
};
let active = concurrency.active.fetch_add(1, Ordering::SeqCst) + 1;
concurrency.max_active.fetch_max(active, Ordering::SeqCst);
std::thread::sleep(concurrency.delay);
let result = self.inner.build_inference_client(timeout);
concurrency.active.fetch_sub(1, Ordering::SeqCst);
result
}
fn validate_oauth(&self, url: &Url) -> bool {
@@ -273,6 +273,104 @@ fn explicit_session_should_merge_client_tools_into_the_native_cache_route() {
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
}
#[test]
fn function_parameters_should_remove_only_nullable_object_roots() {
let request = raw_request(json!({
"model": "client",
"input": "hello",
"tools": [{
"type": "function",
"name": "automation_update",
"parameters": {
"anyOf": [
{
"type": "object",
"properties": {
"title": {
"anyOf": [{"type": "string"}, {"type": "null"}]
}
},
"required": ["title"],
"additionalProperties": false
},
{"type": "null"}
]
}
}]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("nullable object root");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/tools/0/parameters/type"),
Some(&json!("object"))
);
assert_eq!(body.pointer("/tools/0/parameters/anyOf"), None);
assert_eq!(
body.pointer("/tools/0/parameters/properties/title/anyOf"),
Some(&json!([{"type": "string"}, {"type": "null"}]))
);
}
#[test]
fn function_parameters_should_keep_local_object_refs_after_removing_null() {
let request = raw_request(json!({
"model": "client",
"input": "hello",
"tools": [{
"type": "function",
"name": "lookup",
"parameters": {
"$defs": {
"Args": {
"type": "object",
"properties": {"query": {"type": "string"}}
}
},
"anyOf": [{"$ref": "#/$defs/Args"}, {"type": "null"}]
}
}]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("nullable local object ref");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/tools/0/parameters/type"),
Some(&json!("object"))
);
assert_eq!(
body.pointer("/tools/0/parameters/anyOf"),
Some(&json!([{"$ref": "#/$defs/Args"}]))
);
}
#[test]
fn function_parameters_should_report_the_invalid_nullable_root_field() {
let request = raw_request(json!({
"model": "client",
"input": "hello",
"tools": [{
"type": "function",
"name": "invalid",
"parameters": {
"anyOf": [{"type": "object"}, {"type": "string"}, {"type": "null"}]
}
}]
}));
assert_eq!(
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect_err("nullable non-object root"),
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters"
}
);
}
#[test]
fn explicit_session_should_enable_cache_after_codex_additional_tools_normalization() {
let request = raw_request(json!({
@@ -721,7 +819,7 @@ fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
assert_eq!(
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect_err("invalid compaction plaintext"),
GrokRequestEncodeError::InvalidRequestNormalization
GrokRequestEncodeError::InvalidRequestField { field: "input" }
);
}
}
@@ -765,27 +863,42 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
#[test]
fn unsupported_or_ambiguous_tool_contracts_should_fail_before_upstream_io() {
for body in [
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
json!({"model": "client", "input": "x", "tools": [
{"type": "tool_search", "execution": "client"},
{"type": "tool_search", "execution": "server"}
]}),
json!({"model": "client", "input": "x", "tools": [
{"type": "shell"}, {"type": "local_shell"}
]}),
json!({"model": "client", "input": [{
"type": "apply_patch_call",
"call_id": "patch_1",
"operation": {"type": "update_file", "path": "a.txt"}
}]}),
json!({"model": "client", "input": [{"type": "compaction_trigger"}]}),
for (body, field) in [
(
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
"tools",
),
(
json!({"model": "client", "input": "x", "tools": [
{"type": "tool_search", "execution": "client"},
{"type": "tool_search", "execution": "server"}
]}),
"tools",
),
(
json!({"model": "client", "input": "x", "tools": [
{"type": "shell"}, {"type": "local_shell"}
]}),
"tools",
),
(
json!({"model": "client", "input": [{
"type": "apply_patch_call",
"call_id": "patch_1",
"operation": {"type": "update_file", "path": "a.txt"}
}]}),
"input",
),
(
json!({"model": "client", "input": [{"type": "compaction_trigger"}]}),
"input",
),
] {
let request = raw_request(body);
assert_eq!(
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect_err("invalid tool contract"),
GrokRequestEncodeError::InvalidRequestNormalization
GrokRequestEncodeError::InvalidRequestField { field }
);
}
}
+37 -76
View File
@@ -19,21 +19,21 @@ sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
```
分别执行三次以下命令:
为 PostgreSQL 与 Redis 分别生成一个密码:
```bash
openssl rand -hex 24
```
把结果写入 `deploy/config.yaml`:
把两个 48 位十六进制结果分别写入 `deploy/config.yaml` 的:
- `store.database.password`
- `store.redis.password`
- `admin.default_password`
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。管理员初始化密码至少 12 位、不能是
常见弱口令且不能包含 `$`。Compose 通过 `config.yaml` 的凭据桥接区引用同一密码,三个密码
都不需要额外导出为环境变量,也不能嵌入连接 URL。
另行设置 `admin.default_password`。它至少需要 12 个字符,不能是常见弱口令,也不能包含 `$`。
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区
引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL。
Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录:
@@ -45,6 +45,11 @@ chmod 0770 .runtime/data .runtime/logs
`config.yaml` 通过 Compose `configs` 只读挂载。普通 Compose 对本地文件保留宿主机的
UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001` 开放读取权限。
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
模板不重复列出这些默认项。运行后,OpenAI CLI/Desktop 与 xAI CLI 的官方发布检查只更新进程内
版本字段,不回写 `config.yaml`;检查失败时继续使用上一份有效画像。
## 启动
```bash
@@ -78,80 +83,27 @@ cargo run -p codex-proxy-rs
后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析;
Compose 只把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名。
## 持久化
## 持久化与备份
Compose 使用以下绑定目录:
- `.runtime/data` → 应用身份密钥、credential 加密 keyring 和更新状态
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态与临时更新目录
- `.runtime/logs` → 应用文件日志
- `.runtime/postgres` → PostgreSQL
- `.runtime/redis` → Redis AOF
普通 `docker compose down` 不会删除这些目录。删除 `.runtime` 会永久清除本地状态。
PostgreSQL 由 `0001_initial.sql` 创建当前业务结构,Redis 保存可丢失的协调状态。
PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权威存储;账号 credential 当前按
Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如
会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache。
备份必须包含 `.runtime/postgres`。若希望保留短期 Redis 状态、OpenAI 会话亲和和更新状态,
同时备份 `.runtime/redis` 与 `.runtime/data`;后两者丢失不会造成账号 credential 丢失,但会使
缓存和会话锚点重新建立。
完整运行时、Provider、revision 与恢复边界见 [架构文档](../docs/architecture.md)。
## Credential 加密 keyring
首次启动会在 `.runtime/data/credential_keyring` 生成:
- `active_key_id`:新 envelope 唯一使用的 active key ID。
- `envelope_keys/<key-id>.key`:active key 与只读历史解密 key。
- `credential_fingerprint_hmac_key`:稳定 credential 去重域,不能随 envelope key 轮换。
- `resource_pseudonym_hmac_key`:稳定资源伪名域,不能随 envelope key 轮换。
目录权限固定为 `0700`,文件权限固定为 `0600`;符号链接、组/其他用户可读权限、无效 key ID
或重复 key 材料都会让进程拒绝启动。Keyring 不进入 PostgreSQL,必须和 PostgreSQL 备份分开保存;
丢失任何仍被数据库引用的 envelope key 后,对应 credential、Cookie 或 continuation 将无法恢复。
Envelope key 使用“读旧写新”两阶段轮换。多节点必须为每个节点执行相同步骤:
1. 生成相同的新 key ID 和 32-byte key,写入 `envelope_keys/<key-id>.key`,保持当前
`active_key_id` 不变。
2. 滚动重启全部节点,使每个旧进程都先把新 key 加载为历史解密 key。
3. 在全部节点原子替换 `active_key_id`,再滚动重启。已重启节点只用新 key 写入,尚未重启节点
仍能用第二步加载的 key 解密新 envelope。
单机 Compose 可按以下方式准备和激活新 key;不要把命令输出或 key 文件内容写入日志:
```bash
new_key_id="ek-$(openssl rand -hex 16)"
openssl rand -hex 32 | sudo sh -c \
'umask 077; cat > ".runtime/data/credential_keyring/envelope_keys/$1.key"' sh "$new_key_id"
sudo chown 10001:10001 ".runtime/data/credential_keyring/envelope_keys/$new_key_id.key"
# 先重启一次,让当前进程把新 key 作为历史 key 加载。
docker compose -f deploy/compose.yaml restart codex-proxy-rs
sudo env NEW_KEY_ID="$new_key_id" sh -c '
umask 077
printf "%s" "$NEW_KEY_ID" > .runtime/data/credential_keyring/active_key_id.next
chown 10001:10001 .runtime/data/credential_keyring/active_key_id.next
mv -f .runtime/data/credential_keyring/active_key_id.next \
.runtime/data/credential_keyring/active_key_id
'
docker compose -f deploy/compose.yaml restart codex-proxy-rs
unset new_key_id
```
历史 key 只有在所有引用计数均为零、备份也满足同一退役策略后才能删除:
```sql
select 'upstream_credentials' as source, count(*) from upstream_credentials where secret_key_id = $1
union all
select 'codex_account_cookies', count(*) from codex_account_cookies where secret_key_id = $1
union all
select 'conversation_items', count(*) from conversation_items where secret_key_id = $1
union all
select 'continuation_bindings', count(*) from continuation_bindings where secret_key_id = $1;
```
删除仍被引用的历史 key 不会降级或尝试其他 key;解密会以 unknown key fail closed。确认零引用后,
从全部节点删除对应 `.key` 文件并滚动重启,才算完成退役。Fingerprint 与 resource pseudonym 两个
HMAC key 不属于此轮换流程,不能用 envelope key 替换。
## 密码语义
- `admin.default_password` 只在首次创建管理员时使用。
@@ -159,9 +111,10 @@ HMAC key 不属于此轮换流程,不能用 envelope key 替换。
- Redis 在每次容器创建时使用 `redis.password`。
已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis
密码变更后需要使用新密码重建或重新配置 Redis 数据目录。
## 构建与升级
## 镜像升级与源码构建
```bash
docker compose -f deploy/compose.yaml build codex-proxy-rs
@@ -175,6 +128,14 @@ docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build
```
仓库维护者发布新版本时必须从干净且已同步上游的分支运行:
```bash
release/publish <version>
```
该脚本负责更新 `release/version.yaml`、创建版本提交和带注释 tag,并原子推送分支与 tag。
构建元数据仍可作为一次性进程环境传入,不需要 `.env` 文件:
```bash
@@ -184,22 +145,22 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
docker compose -f deploy/compose.yaml build codex-proxy-rs
```
### 管理端 Release 更新
### 管理端在线更新
Compose 已显式装配正式更新所需的运行参数:
Compose 已显式装配正式发布构建所需的运行参数:
- `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和旧前端静态目录。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
- `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE`:全部位于持久化的
`.runtime/data`。
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由
Compose 的 `restart: unless-stopped` 拉起新进程。
Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.tar.gz` 和
`checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和 tar
路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.tar.gz` 与
`checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和
归档路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
`*.backup`,管理端 rollback 会交换当前文件与这份备份。更新状态和跨进程锁可在以下位置排查:
```text
+5 -3
View File
@@ -5,7 +5,7 @@
# sudo chown "$(id -u):10001" deploy/config.yaml
# chmod 640 deploy/config.yaml
#
# 三个密码建议分别执行 `openssl rand -hex 24` 生成。
# PostgreSQL 与 Redis 密码分别执行 `openssl rand -hex 24` 生成;管理员初始密码单独设置。
schema_version: 1
@@ -51,8 +51,9 @@ api:
request_id_header: 'x-request-id'
openai:
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
wire_profile:
# 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。
# Provider 每 24 小时读取官方 npm metadata 与 Desktop appcast,仅在进程内更新版本字段。
originator: 'Codex Desktop'
codex_version: '0.145.0'
desktop_version: '26.721.31836'
@@ -64,8 +65,9 @@ openai:
verified_at: '2026-07-25T00:00:00+08:00'
xai:
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
wire_profile:
# 对照 grok2api 当前推荐构建核验;更新前应重新抓包确认。
# Provider 每 24 小时读取官方 npm metadata,仅在进程内更新 client_version。
client_identifier: 'grok-shell'
client_version: '0.2.106'
client_mode: 'headless'
+236
View File
@@ -0,0 +1,236 @@
# Codex Proxy RS 接口
本文列出 v3.0.0 当前公开 HTTP 接口。路由事实以
`backend/crates/gateway-api/src` 中的 router 为准。
## 1. 鉴权与公共约定
### 客户端接口
所有 `/v1/*` 请求都使用管理端创建的 Client Key:
```http
Authorization: Bearer sk_...
```
Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不会跨 Provider fallback。
### 管理接口
除登录、会话状态和登出外,所有 `/api/admin/*` 请求都需要以下任一鉴权方式:
- 浏览器登录后得到的 `cpr_admin_session` Cookie;
- `x-api-key: <admin-api-key>`。
已鉴权的管理请求还必须携带非空 `x-request-id`。管理端响应统一带
`Cache-Control: no-store`。
普通成功响应使用以下信封:
```json
{
"code": 200,
"message": "OK",
"data": {}
}
```
错误响应仍使用 `code`、`message`、`data`,其中 `data` 为 `null`。稳定业务码包括参数错误
`40001`、会话缺失 `40101`、资源不存在 `40401`、revision 冲突 `40901`、上游失败 `50201`
和依赖不可用 `50301`。
### 配置 revision
会改变路由快照或安全配置的 mutation 使用 `expectedConfigRevision` 做乐观并发控制。客户端应从
账号、Client Key 或设置查询结果读取最新 `configRevision`;收到 `40901` 后重新读取,不应盲目重试
旧 revision。
## 2. 健康检查
| 方法 | 路由 | 鉴权 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/healthz` | 无 | Core、Store 和后台任务健康时返回 `204`,否则返回 `503` |
## 3. Responses 与模型目录
请求正文上限为 16 MiB。
| 方法 | 路由 | 说明 |
| --- | --- | --- |
| `POST` | `/v1/responses` | OpenAI Responses JSON;`stream=true` 返回 SSE,否则返回完整 JSON |
| `GET` | `/v1/responses` | 通过 HTTP Upgrade 建立 Responses WebSocket |
| `POST` | `/v1/responses/review` | 使用同一 Responses 合同发起 review 子代理请求 |
| `GET` | `/v1/models` | 返回当前 Client Key 所属 Provider 的可用公开模型 |
| `GET` | `/v1/models/catalog` | 返回 Codex 客户端使用的模型目录 |
| `GET` | `/v1/models/{model_id}/info` | 返回 Codex 客户端使用的单模型信息 |
| `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 |
OpenAI 路径保留客户端 Responses wire 语义;xAI 所需的协议转换只在 xAI Provider 内完成。模型映射
是全局精确映射,未命中时模型名原样交给所属 Provider。
## 4. 管理员认证
| 方法 | 路由 | 请求 | 说明 |
| --- | --- | --- | --- |
| `POST` | `/api/admin/auth/login` | `{ username?, password }` | 创建管理员会话并设置 Cookie |
| `GET` | `/api/admin/auth/status` | 无 | 返回当前 Cookie 是否已认证 |
| `POST` | `/api/admin/auth/logout` | 无 | 删除当前会话并清除 Cookie |
## 5. 账号
账号 API 使用统一路由,不存在 Provider Instance 或 Provider 专属账号路由。需要 Provider 的请求只接受
`provider: "openai" | "xai"`。
| 方法 | 路由 | 主要 query/body | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
| `POST` | `/api/admin/accounts/import` | `{ provider, expectedConfigRevision, data }` | 导入或按上游身份更新账号 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId, expectedConfigRevision }` | 手工刷新账号 credential |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId, expectedConfigRevision }` | 启用账号 |
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId, expectedConfigRevision }` | 禁用账号 |
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds, expectedConfigRevision }` | 批量删除 1–200 个账号 |
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 立即刷新该账号额度 |
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
| `POST` | `/api/admin/accounts/oauth/start` | OAuth start 字段 | 创建 OpenAI 或 xAI OAuth flow |
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback 并写入账号 |
账号列表支持以下稳定值:
- `provider`: `all`、`openai`、`xai`;
- `status`: `active`、`expired`、`quota_exhausted`、`disabled`、`banned`;
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
- `sortDirection`: `asc`、`desc`。
导入的 `data` 必须是 JSON object,内部 schema 由目标 Provider 独占解释:
- OpenAI 接受 OAuth 账号文档、账号 bundle 和 Agent Identity;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
- xAI API Key 不是受支持的账号 credential;
- 导入仍会通过 Provider 的身份或刷新链路校验,不能只凭文件外形写入账号。
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
`taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种
认证类型,导入时按每个账号的认证模式分别解析。
```json
{
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_...",
"accountId": "...",
"userId": "...",
"authMode": "agentIdentity",
"agentRuntimeId": "...",
"agentPrivateKey": "...",
"taskId": "..."
}]
}
```
OpenAI rotation 请求字段为:
```json
{
"provider": "openai",
"accountId": "acct_...",
"expectedConfigRevision": 1,
"expectedCredentialRevision": 1,
"accessToken": "...",
"refreshToken": "..."
}
```
OAuth start 使用:
```json
{
"provider": "openai",
"expectedConfigRevision": 1,
"name": "account name",
"accountId": null,
"expectedCredentialRevision": null
}
```
重新授权已有账号时,`accountId` 与 `expectedCredentialRevision` 必须同时提供。
## 6. Client Key
| 方法 | 路由 | 主要 query/body | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/admin/client-keys` | `cursor`、`limit`、`search`、`sortBy`、`sortDirection` | 游标分页查询 |
| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建绑定 Provider 的 Client Key |
| `GET` | `/api/admin/client-keys/reveal` | `id` | 显式读取完整明文 Key |
| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、Provider 和限额 |
| `POST` | `/api/admin/client-keys/enable` | `{ id, expectedConfigRevision }` | 启用 |
| `POST` | `/api/admin/client-keys/disable` | `{ id, expectedConfigRevision }` | 禁用 |
| `POST` | `/api/admin/client-keys/delete` | `{ id, expectedConfigRevision }` | 删除 |
创建字段为 `expectedConfigRevision`、`name`、可选 `label`、`providerKind`、`maxConcurrency`、
`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
## 7. 运行设置
| 方法 | 路由 | 说明 |
| --- | --- | --- |
| `GET` | `/api/admin/settings` | 读取运行设置与 `configRevision` |
| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 |
| `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 |
| `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key |
| `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key |
设置更新字段包括:
```text
expectedConfigRevision
modelMappings
refreshMarginSeconds
refreshConcurrency
maxConcurrentPerAccount
requestIntervalMs
rotationStrategy
usageRetentionDays
opsEventRetentionDays
auditRetentionDays
```
`rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。
## 8. Dashboard、用量与错误
| 方法 | 路由 | 说明 |
| --- | --- | --- |
| `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind`、`startTime`、`endTime` |
| `GET` | `/api/admin/dashboard/trend` | Dashboard 趋势;`kind=usage|latency|errors` |
| `GET` | `/api/admin/usage/records` | 请求记录分页列表 |
| `GET` | `/api/admin/usage/records/detail` | 按 `id` 查询请求详情 |
| `GET` | `/api/admin/usage/records/summary` | 当前筛选条件的请求汇总 |
| `GET` | `/api/admin/usage/insights/overview` | 用量、成本与成功率洞察 |
| `GET` | `/api/admin/usage/insights/diagnostics` | 按维度聚合诊断 |
| `GET` | `/api/admin/operations/errors` | 运维错误分页列表 |
用量查询可组合页码/游标、时间范围、Provider、Client Key、账号、模型、route、transport、状态码、
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
## 9. 版本、更新与重启
| 方法 | 路由 | 主要 query/body | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/admin/system/version` | 无 | 当前构建、部署模式和可用更新 |
| `GET` | `/api/admin/system/update/detail` | `refresh=true|false` | 读取或强制刷新 Release 详情 |
| `GET` | `/api/admin/system/update/events` | 无 | SSE 更新事件流 |
| `POST` | `/api/admin/system/update` | 可选 `{ targetVersion }` | 开始在线更新 |
| `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 |
| `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 |
| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 |
在线更新仅在当前部署模式、Release 资产和进程重启能力都满足要求时可用。仓库发版流程见
[部署文档](../deploy/README.md) 与根目录 [README](../README.md)。
+3 -2
View File
@@ -130,7 +130,7 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
| `admin_audit_events` | 管理 mutation 审计 |
| `client_api_keys` | 客户端鉴权、限额与授权范围 |
| `runtime_settings` | 全局配置与 `config_revision` |
| `provider_accounts` | 账号资料、加密 credential、revision、quota、cooldown |
| `provider_accounts` | 账号资料、Provider-owned 明文 credential JSON、revision、quota、cooldown |
| `model_requests` | 请求、attempt、计费、交付与恢复事实 |
| `ops_events` | 脱敏运行事件 |
@@ -138,7 +138,8 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
- 一个事实只存一次;可表达关系使用真实 FK 与支持索引。
- Provider 差异只进入受 schema/version 校验的 JSONB 边界。
- secret 只保存加密 envelope;日志、Debug、API 和 audit 不输出 secret。
- credential JSON 以 Provider 自己的 schema 明文保存在 `provider_credentials_json`;日志、Debug、API 和 audit
不输出其中的 secret,Provider 只在需要发往上游时把值包装为运行时 secret。
- stale recovery 只把超时 `running` 请求收敛为 `incomplete`,不重放业务请求。
- `ops_events` 当前逐条同步持久化,`occurrence_count=1`;OpsFlush 可禁用,不存在隐藏内存聚合权威。
- retention 只删除已满足保留规则的历史事实,不改变运行中请求。
+1 -1
View File
@@ -6,7 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260726" />
<title>Codex Proxy RS</title>
</head>
<body>
+7 -7
View File
@@ -1,9 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#111827"/>
<g transform="translate(10 8) scale(1.85)" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3.1-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7S8.22 3.26 10 5.26A8.3 8.3 0 0 1 12 5Z"/>
<path d="M8 14v.5"/>
<path d="M16 14v.5"/>
<path d="M11.25 16.25h1.5L12 17l-.75-.75Z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" role="img" aria-labelledby="title">
<title id="title">Codex Proxy</title>
<rect x="48" y="48" width="416" height="416" rx="88" fill="#1c2a3d" />
<g fill="none" stroke="#fff" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
</g>
<path d="m407 105-119 153-183 149 124-157Z" fill="#fff" />
</svg>

Before

Width:  |  Height:  |  Size: 594 B

After

Width:  |  Height:  |  Size: 457 B

+5 -1
View File
@@ -108,7 +108,11 @@ const labelClasses = computed(() => [
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
>
<LoaderCircle v-if="loading" class="animate-spin" :size="loadingIconSize[size]" />
<LoaderCircle
v-if="loading"
class="shrink-0 animate-spin origin-center transform-view will-change-transform"
:size="loadingIconSize[size]"
/>
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
</span>
+7 -1
View File
@@ -24,6 +24,11 @@ const props = withDefaults(
const model = defineModel<string>({ default: '' })
// number 输入也保持字符串契约,避免原生 v-model 隐式转换为 number。
function updateModel(event: Event) {
model.value = (event.target as HTMLInputElement).value
}
const containerClasses = computed(() => [
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
props.disabled
@@ -64,7 +69,7 @@ const inputClasses = computed(() => [
<slot name="prefix" />
</span>
<input
v-model="model"
:value="model"
class="base-input__field"
:class="inputClasses"
:name="name"
@@ -74,6 +79,7 @@ const inputClasses = computed(() => [
:autocomplete="autocomplete"
:aria-label="ariaLabel"
:aria-invalid="error ? 'true' : undefined"
@input="updateModel"
>
<span v-if="$slots.suffix" :class="iconClasses">
<slot name="suffix" />
@@ -6,7 +6,7 @@ defineProps<{
</script>
<template>
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
<header class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
{{ title }}
@@ -20,7 +20,10 @@ defineProps<{
</slot>
</p>
</div>
<div v-if="$slots.actions" class="mt-0.5 flex shrink-0 items-center gap-2">
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center gap-2 max-sm:w-full max-sm:justify-end"
>
<slot name="actions" />
</div>
</header>
+130 -62
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useEventListener, useResizeObserver, useScroll, useTimeoutFn } from '@vueuse/core'
import { useEventListener, useResizeObserver, useTimeoutFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
const props = withDefaults(
defineProps<{
@@ -42,11 +42,26 @@ const verticalTrackHovering = shallowRef(false)
const horizontalTrackHovering = shallowRef(false)
const dragging = shallowRef(false)
const horizontalDragging = shallowRef(false)
const dragDocument = computed(() =>
dragging.value || horizontalDragging.value ? document : null,
)
interface AxisMetrics {
scrollRange: number
thumbRange: number
}
const verticalMetrics: AxisMetrics = { scrollRange: 0, thumbRange: 0 }
const horizontalMetrics: AxisMetrics = { scrollRange: 0, thumbRange: 0 }
let dragStartY = 0
let dragStartScrollTop = 0
let horizontalDragStartX = 0
let horizontalDragStartScrollLeft = 0
let scrollTopPosition = 0
let scrollLeftPosition = 0
let scrollFrameId: number | undefined
let rootHovering = false
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
immediate: false,
})
@@ -88,30 +103,6 @@ const horizontalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
)
function trackHeight(wrap: HTMLElement) {
return clamp(wrap.clientHeight - 8, 0, Number.POSITIVE_INFINITY)
}
function maxScrollTop(wrap: HTMLElement) {
return clamp(wrap.scrollHeight - wrap.clientHeight, 0, Number.POSITIVE_INFINITY)
}
function trackWidth(wrap: HTMLElement) {
return clamp(wrap.clientWidth - 8, 0, Number.POSITIVE_INFINITY)
}
function maxScrollLeft(wrap: HTMLElement) {
return clamp(wrap.scrollWidth - wrap.clientWidth, 0, Number.POSITIVE_INFINITY)
}
function maxThumbTop(wrap: HTMLElement) {
return clamp(trackHeight(wrap) - thumbHeight.value, 0, Number.POSITIVE_INFINITY)
}
function maxHorizontalThumbLeft(wrap: HTMLElement) {
return clamp(trackWidth(wrap) - horizontalThumbWidth.value, 0, Number.POSITIVE_INFINITY)
}
function showScrollbar() {
visible.value = true
}
@@ -122,7 +113,9 @@ function clearHideTimer() {
function scheduleHideScrollbar() {
clearHideTimer()
startHideTimer()
if (!rootHovering) {
startHideTimer()
}
}
function hideScrollbar() {
@@ -135,6 +128,16 @@ function activateScrollbar() {
scheduleHideScrollbar()
}
function handleRootMouseEnter() {
rootHovering = true
activateScrollbar()
}
function handleRootMouseLeave() {
rootHovering = false
hideScrollbar()
}
type ScrollbarAxis = 'vertical' | 'horizontal'
function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
@@ -163,48 +166,90 @@ function update() {
return
}
updateVerticalScrollbar(wrap)
updateHorizontalScrollbar(wrap)
measureVerticalScrollbar(wrap)
measureHorizontalScrollbar(wrap)
syncScrollbarPosition(wrap, true)
}
function updateVerticalScrollbar(wrap: HTMLElement) {
function measureVerticalScrollbar(wrap: HTMLElement) {
if (!props.vertical) {
verticalMetrics.scrollRange = 0
verticalMetrics.thumbRange = 0
thumbHeight.value = 0
thumbTop.value = 0
return
}
const scrollRange = maxScrollTop(wrap)
const availableTrackHeight = trackHeight(wrap)
const clientHeight = wrap.clientHeight
const scrollHeight = wrap.scrollHeight
const scrollRange = clamp(scrollHeight - clientHeight, 0, Number.POSITIVE_INFINITY)
const availableTrackHeight = clamp(clientHeight - 8, 0, Number.POSITIVE_INFINITY)
if (scrollRange <= overflowTolerance || availableTrackHeight <= 0) {
wrap.scrollTop = 0
verticalMetrics.scrollRange = 0
verticalMetrics.thumbRange = 0
thumbHeight.value = 0
thumbTop.value = 0
return
}
const ratio = wrap.clientHeight / wrap.scrollHeight
thumbHeight.value = clamp(availableTrackHeight * ratio, 32, availableTrackHeight)
thumbTop.value = (wrap.scrollTop / scrollRange) * maxThumbTop(wrap)
const ratio = clientHeight / scrollHeight
const nextThumbHeight = clamp(availableTrackHeight * ratio, 32, availableTrackHeight)
verticalMetrics.scrollRange = scrollRange
verticalMetrics.thumbRange = availableTrackHeight - nextThumbHeight
thumbHeight.value = nextThumbHeight
}
function updateHorizontalScrollbar(wrap: HTMLElement) {
function measureHorizontalScrollbar(wrap: HTMLElement) {
if (!props.horizontal) {
horizontalMetrics.scrollRange = 0
horizontalMetrics.thumbRange = 0
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
return
}
const scrollRange = maxScrollLeft(wrap)
const availableTrackWidth = trackWidth(wrap)
const clientWidth = wrap.clientWidth
const scrollWidth = wrap.scrollWidth
const scrollRange = clamp(scrollWidth - clientWidth, 0, Number.POSITIVE_INFINITY)
const availableTrackWidth = clamp(clientWidth - 8, 0, Number.POSITIVE_INFINITY)
if (scrollRange <= overflowTolerance || availableTrackWidth <= 0) {
wrap.scrollLeft = 0
horizontalMetrics.scrollRange = 0
horizontalMetrics.thumbRange = 0
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
return
}
const ratio = wrap.clientWidth / wrap.scrollWidth
horizontalThumbWidth.value = clamp(availableTrackWidth * ratio, 32, availableTrackWidth)
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
const ratio = clientWidth / scrollWidth
const nextThumbWidth = clamp(availableTrackWidth * ratio, 32, availableTrackWidth)
horizontalMetrics.scrollRange = scrollRange
horizontalMetrics.thumbRange = availableTrackWidth - nextThumbWidth
horizontalThumbWidth.value = nextThumbWidth
}
function updateVerticalThumbPosition(scrollTop: number) {
const { scrollRange, thumbRange } = verticalMetrics
thumbTop.value
= scrollRange > 0 ? (clamp(scrollTop, 0, scrollRange) / scrollRange) * thumbRange : 0
}
function updateHorizontalThumbPosition(scrollLeft: number) {
const { scrollRange, thumbRange } = horizontalMetrics
horizontalThumbLeft.value
= scrollRange > 0 ? (clamp(scrollLeft, 0, scrollRange) / scrollRange) * thumbRange : 0
}
function syncScrollbarPosition(wrap: HTMLElement, force = false) {
const scrollTop = wrap.scrollTop
const scrollLeft = wrap.scrollLeft
if (force || scrollTop !== scrollTopPosition) {
updateVerticalThumbPosition(scrollTop)
}
if (force || scrollLeft !== scrollLeftPosition) {
updateHorizontalThumbPosition(scrollLeft)
}
scrollTopPosition = scrollTop
scrollLeftPosition = scrollLeft
}
async function scrollToTop() {
@@ -230,13 +275,14 @@ async function scrollToBottom() {
update()
}
function handleScroll() {
function flushScrollFrame() {
scrollFrameId = undefined
const wrap = wrapRef.value
if (!wrap) {
return
}
update()
syncScrollbarPosition(wrap)
activateScrollbar()
emit('scroll', {
scrollTop: wrap.scrollTop,
@@ -244,6 +290,14 @@ function handleScroll() {
})
}
function handleScroll() {
if (scrollFrameId !== undefined) {
return
}
scrollFrameId = requestAnimationFrame(flushScrollFrame)
}
function handleTrackPointerDown(event: PointerEvent) {
if (event.target !== event.currentTarget) {
return
@@ -254,11 +308,11 @@ function handleTrackPointerDown(event: PointerEvent) {
return
}
update()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
activateScrollbar()
const nextThumbTop = event.clientY - rect.top - thumbHeight.value / 2
const scrollRange = maxScrollTop(wrap)
const thumbRange = maxThumbTop(wrap)
const { scrollRange, thumbRange } = verticalMetrics
wrap.scrollTop
= thumbRange > 0 ? (clamp(nextThumbTop, 0, thumbRange) / thumbRange) * scrollRange : 0
}
@@ -273,11 +327,11 @@ function handleHorizontalTrackPointerDown(event: PointerEvent) {
return
}
update()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
activateScrollbar()
const nextThumbLeft = event.clientX - rect.left - horizontalThumbWidth.value / 2
const scrollRange = maxScrollLeft(wrap)
const thumbRange = maxHorizontalThumbLeft(wrap)
const { scrollRange, thumbRange } = horizontalMetrics
wrap.scrollLeft
= thumbRange > 0 ? (clamp(nextThumbLeft, 0, thumbRange) / thumbRange) * scrollRange : 0
}
@@ -289,6 +343,7 @@ function handleThumbPointerDown(event: PointerEvent) {
}
event.preventDefault()
update()
dragging.value = true
visible.value = true
clearHideTimer()
@@ -303,6 +358,7 @@ function handleHorizontalThumbPointerDown(event: PointerEvent) {
}
event.preventDefault()
update()
horizontalDragging.value = true
visible.value = true
clearHideTimer()
@@ -320,12 +376,11 @@ function handleThumbPointerMove(event: PointerEvent) {
return
}
const thumbRange = maxThumbTop(wrap)
const { scrollRange, thumbRange } = verticalMetrics
if (thumbRange <= 0) {
return
}
const scrollRange = maxScrollTop(wrap)
wrap.scrollTop = dragStartScrollTop + ((event.clientY - dragStartY) / thumbRange) * scrollRange
}
@@ -339,12 +394,11 @@ function handleHorizontalThumbPointerMove(event: PointerEvent) {
return
}
const thumbRange = maxHorizontalThumbLeft(wrap)
const { scrollRange, thumbRange } = horizontalMetrics
if (thumbRange <= 0) {
return
}
const scrollRange = maxScrollLeft(wrap)
wrap.scrollLeft
= horizontalDragStartScrollLeft
+ ((event.clientX - horizontalDragStartX) / thumbRange) * scrollRange
@@ -368,19 +422,33 @@ function handleHorizontalThumbPointerUp() {
activateScrollbar()
}
function handleDocumentPointerMove(event: PointerEvent) {
handleThumbPointerMove(event)
handleHorizontalThumbPointerMove(event)
}
function handleDocumentPointerUp() {
handleThumbPointerUp()
handleHorizontalThumbPointerUp()
}
onMounted(async () => {
await nextTick()
update()
})
onBeforeUnmount(() => {
if (scrollFrameId !== undefined) {
cancelAnimationFrame(scrollFrameId)
}
})
useResizeObserver([wrapRef, viewRef], update)
useScroll(wrapRef, { onScroll: handleScroll })
useEventListener(document, 'pointermove', handleThumbPointerMove)
useEventListener(document, 'pointerup', handleThumbPointerUp)
useEventListener(document, 'pointermove', handleHorizontalThumbPointerMove)
useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
useEventListener(rootRef, ['mouseenter', 'mousemove'], activateScrollbar)
useEventListener(rootRef, 'mouseleave', hideScrollbar)
useEventListener(wrapRef, 'scroll', handleScroll, { passive: true })
useEventListener(dragDocument, 'pointermove', handleDocumentPointerMove)
useEventListener(dragDocument, ['pointerup', 'pointercancel'], handleDocumentPointerUp)
useEventListener(rootRef, 'mouseenter', handleRootMouseEnter)
useEventListener(rootRef, 'mouseleave', handleRootMouseLeave)
useEventListener(verticalTrackRef, 'mouseenter', () => handleTrackMouseEnter('vertical'))
useEventListener(verticalTrackRef, 'mouseleave', () => handleTrackMouseLeave('vertical'))
useEventListener(verticalTrackRef, 'pointerdown', handleTrackPointerDown)
@@ -15,6 +15,8 @@ interface UseHorizontalStickyShadowOptions {
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
let horizontalScrollRange = 0
let scrollLeftPosition = 0
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
@@ -25,11 +27,26 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
}
function resetHorizontalScrollbar() {
horizontalScrollRange = 0
scrollLeftPosition = 0
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
syncHeaderScroll(0)
}
function updateHorizontalScrollbar() {
function syncHeaderScroll(scrollLeft: number) {
const headerWrap = options.headerWrapRef.value
if (headerWrap && headerWrap.scrollLeft !== scrollLeft) {
headerWrap.scrollLeft = scrollLeft
}
}
function updateHorizontalScrollPosition(scrollLeft: number) {
horizontalScrolled.value = scrollLeft > 0
horizontalCanScrollRight.value = scrollLeft < horizontalScrollRange - 1
}
function measureHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap || !options.hasRows.value) {
resetHorizontalScrollbar()
@@ -42,21 +59,25 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
return
}
horizontalScrolled.value = wrap.scrollLeft > 0
horizontalCanScrollRight.value = wrap.scrollLeft < scrollRange - 1
horizontalScrollRange = scrollRange
scrollLeftPosition = wrap.scrollLeft
syncHeaderScroll(scrollLeftPosition)
updateHorizontalScrollPosition(scrollLeftPosition)
}
function handleTableScroll() {
const wrap = scrollWrap()
if (wrap && options.headerWrapRef.value) {
options.headerWrapRef.value.scrollLeft = wrap.scrollLeft
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
if (payload.scrollLeft === scrollLeftPosition) {
return
}
updateHorizontalScrollbar()
scrollLeftPosition = payload.scrollLeft
syncHeaderScroll(scrollLeftPosition)
updateHorizontalScrollPosition(scrollLeftPosition)
}
onMounted(async () => {
await nextTick()
updateHorizontalScrollbar()
measureHorizontalScrollbar()
})
useResizeObserver(
@@ -64,12 +85,12 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
[scrollWrap(), options.tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
updateHorizontalScrollbar,
measureHorizontalScrollbar,
)
watch(options.watchSources, async () => {
await nextTick()
updateHorizontalScrollbar()
measureHorizontalScrollbar()
})
return {
+14 -6
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import {
ArrowUpCircle,
Cat,
ChartNoAxesColumn,
Info,
KeyRound,
@@ -118,7 +117,11 @@ const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
const sidebarWidth = computed(() => (isCollapsed.value ? 88 : 256))
const collapsedSidebarWidth = 88
const expandedSidebarWidth = 251
const sidebarWidth = computed(() =>
isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth,
)
const brandLabelVisible = shallowRef(!isCollapsed.value)
const themeToggleLabel = computed(() =>
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
@@ -209,7 +212,7 @@ function animateSidebarWidth(collapsed: boolean) {
return
}
const targetWidth = collapsed ? 88 : 256
const targetWidth = collapsed ? collapsedSidebarWidth : expandedSidebarWidth
const currentWidth = sidebarEl.value.getBoundingClientRect().width
if (prefersReducedMotion()) {
@@ -326,7 +329,7 @@ onBeforeUnmount(() => {
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar)"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-[251px] basis-[251px]',
]"
>
<div
@@ -336,9 +339,14 @@ onBeforeUnmount(() => {
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-brand-mark-bg)"
>
<Cat :size="27" stroke-width="2" />
<img
src="/favicon.svg"
alt=""
class="block size-9 select-none"
draggable="false"
>
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
@@ -0,0 +1,240 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { Menu } from '@lucide/vue'
import {
useDraggable,
useElementSize,
useScreenSafeArea,
useStorage,
useWindowSize,
} from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onScopeDispose,
shallowRef,
useTemplateRef,
watch,
} from 'vue'
interface StoredPosition {
initialized: boolean
side: 'left' | 'right'
x: number
y: number
}
const emit = defineEmits<{
open: []
}>()
const edgeGap = 12
const defaultButtonSize = 44
const dragClickThreshold = 6
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
initialWidth: 390,
initialHeight: 760,
type: 'visual',
})
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
width: defaultButtonSize,
height: defaultButtonSize,
})
const safeArea = useScreenSafeArea()
const storedPosition = useStorage<StoredPosition>(
storageKey,
{
initialized: false,
side: 'left',
x: edgeGap,
y: 136,
},
undefined,
{ mergeDefaults: true, shallow: true },
)
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
const ignoreNextClick = shallowRef(false)
const positionReady = shallowRef(false)
let ignoreClickTimer: number | undefined
const bounds = computed(() => {
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
const windowWidth = positiveNumber(viewportWidth.value, 390)
const windowHeight = positiveNumber(viewportHeight.value, 760)
const minX = parseCssPixel(safeArea.left.value) + edgeGap
const maxX = Math.max(
minX,
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
)
const minY = parseCssPixel(safeArea.top.value) + edgeGap
const maxY = Math.max(
minY,
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
)
return { minX, maxX, minY, maxY }
})
const { x, y, isDragging } = useDraggable(toggleEl, {
initialValue: {
x: storedPosition.value.x,
y: storedPosition.value.y,
},
preventDefault: false,
stopPropagation: false,
onEnd(position) {
applyPosition(snapPosition(position.x, position.y))
},
})
const toggleStyle = computed<CSSProperties>(() => ({
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
touchAction: 'none',
}))
function handlePointerDown(event: PointerEvent) {
pointerStart.value = { x: event.clientX, y: event.clientY }
const target = event.currentTarget
if (target instanceof HTMLElement)
target.setPointerCapture?.(event.pointerId)
}
function handlePointerUp(event: PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement)
target.releasePointerCapture?.(event.pointerId)
const moved = Math.hypot(
event.clientX - pointerStart.value.x,
event.clientY - pointerStart.value.y,
)
ignoreNextClick.value = true
window.clearTimeout(ignoreClickTimer)
ignoreClickTimer = window.setTimeout(() => {
ignoreNextClick.value = false
}, 320)
if (moved <= dragClickThreshold) {
emit('open')
}
}
function handleClick() {
if (isDragging.value || ignoreNextClick.value) {
ignoreNextClick.value = false
return
}
emit('open')
}
function defaultPosition() {
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
return snapPosition(bounds.value.minX, targetY)
}
function restorePosition() {
if (!storedPosition.value.initialized) {
applyPosition(defaultPosition())
return
}
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
applyPosition({
initialized: true,
side: storedPosition.value.side,
x: sideX,
y: storedPosition.value.y,
})
}
function snapPosition(rawX: number, rawY: number) {
const { minX, maxX, minY, maxY } = bounds.value
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
return {
initialized: true,
side,
x: side === 'left' ? minX : maxX,
y: clamp(rawY, minY, maxY),
} satisfies StoredPosition
}
function applyPosition(position: StoredPosition) {
const { minX, maxX, minY, maxY } = bounds.value
const next = {
initialized: true,
side: position.side,
x: clamp(position.x, minX, maxX),
y: clamp(position.y, minY, maxY),
} satisfies StoredPosition
x.value = next.x
y.value = next.y
storedPosition.value = next
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function positiveNumber(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback
}
function parseCssPixel(value: string) {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
}
onMounted(async () => {
await nextTick()
safeArea.update()
restorePosition()
positionReady.value = true
})
watch(
bounds,
() => {
if (!positionReady.value)
return
restorePosition()
},
{ flush: 'post' },
)
onScopeDispose(() => {
window.clearTimeout(ignoreClickTimer)
})
</script>
<template>
<div
ref="toggleEl"
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
:class="positionReady ? 'opacity-100' : 'opacity-0'"
:style="toggleStyle"
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
: 'cursor-grab'
"
aria-label="打开侧边栏"
@pointerdown="handlePointerDown"
@pointerup="handlePointerUp"
@click="handleClick"
>
<Menu class="size-5" />
</button>
</div>
</template>
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { Menu } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
open: []
}>()
</script>
<template>
<div
class="sticky top-0 z-40 -mx-4 -mt-4 mb-4 flex h-15 items-center bg-(--cp-bg-page)/96 px-4 backdrop-blur-sm min-[961px]:hidden"
>
<BaseButton
icon-only
size="md"
variant="default"
label="打开侧边栏"
class="shadow-[0_12px_28px_-20px_var(--cp-shadow-sticky)]"
@click="emit('open')"
>
<Menu class="size-5" />
</BaseButton>
</div>
</template>
+2 -2
View File
@@ -9,7 +9,7 @@ import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import MobileSidebarToolbar from './components/MobileSidebarToolbar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal.vue'
const uiStore = useUiStore()
@@ -77,12 +77,12 @@ watch(
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar
ref="pageScrollbarRef"
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
>
<MobileSidebarToolbar v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
+16 -4
View File
@@ -1,4 +1,4 @@
import { useEventSource } from '@vueuse/core'
import { until, useEventSource } from '@vueuse/core'
import { delay } from 'es-toolkit'
import { defineStore } from 'pinia'
@@ -14,6 +14,7 @@ import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/async'
const maxUpdateLogs = 200
const updateEventReadyTimeoutMs = 3_000
const restartReadyTimeoutMs = 60_000
const restartProbeTimeoutMs = 2_000
const restartReadyPollIntervalMs = 500
@@ -106,19 +107,30 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
})
function connectUpdateEvents(force = false) {
async function connectUpdateEvents(force = false) {
if (force) {
disconnectUpdateEvents()
}
else if (updateEventSource.value) {
return
return updateEventStatus.value === 'OPEN'
}
updateStreamError.value = ''
openUpdateEventSource()
if (!updateEventSource.value) {
updateStreamError.value = '当前浏览器不支持实时更新日志'
return false
}
if (updateEventStatus.value !== 'OPEN') {
await until(updateEventStatus).toBe('OPEN', {
timeout: updateEventReadyTimeoutMs,
})
}
const connected = updateEventStatus.value === 'OPEN'
if (!connected)
updateStreamError.value = '更新日志连接超时'
return connected
}
function disconnectUpdateEvents() {
@@ -186,7 +198,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
return null
clearUpdateLogs()
connectUpdateEvents(true)
await connectUpdateEvents(true)
updating.value = true
updateError.value = ''
updateSuccess.value = false
+1
View File
@@ -8,6 +8,7 @@
--cp-bg-tertiary: #eef6ff;
--cp-bg-nav-active: #e9eef5;
--cp-bg-dark: #111827;
--cp-brand-mark-bg: #1c2a3d;
--cp-white: #ffffff;
--cp-transparent: #ffffff00;
--cp-overlay-scrim: #0e17264d;
@@ -2,7 +2,7 @@
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
import type { AccountRow } from '../constants'
import { Openai, Xai } from '@boxicons/vue'
import { Copy, KeyRound, Upload } from '@lucide/vue'
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
import { useClipboard, useFileDialog } from '@vueuse/core'
import { computed, ref } from 'vue'
@@ -51,7 +51,7 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
})
const modeOptions = computed(() => accountProviderModeOptions(form.value.provider))
const isProviderSelected = computed(() => form.value.provider === 'openai' || form.value.provider === 'xai')
const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider))
const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value)
const provider = computed({
@@ -91,10 +91,17 @@ const oauthCallback = computed({
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
const isXai = computed(() => form.value.provider === 'xai')
const importFileLabel = computed(() => mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件')
const isBatch = computed(() => form.value.provider === 'batch')
const importFileLabel = computed(() => {
if (isBatch.value)
return '批量账号文件'
return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件'
})
const importFilePlaceholder = computed(() => mode.value === 'agent_identity'
? '粘贴 Agent 身份文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
: isBatch.value
? '粘贴 CPR 多平台导出文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
const accountName = computed(() => {
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
@@ -140,6 +147,8 @@ const description = computed<string | undefined>(() => {
return undefined
if (props.reauthorizing)
return '完成授权后粘贴回调地址,系统会更新账号凭据'
if (isBatch.value)
return '导入 CPR 多平台账号包,系统会按文档中的平台自动分流'
if (isXai.value) {
return mode.value === 'oauth'
? '复制 xAI 授权链接,完成后粘贴回调地址,不使用 xAI API Key'
@@ -152,7 +161,7 @@ const description = computed<string | undefined>(() => {
return '导入 CPR、Sub2API 或 CPA 账号文件,已存在账号会更新'
})
function selectProvider(value: 'openai' | 'xai') {
function selectProvider(value: 'openai' | 'xai' | 'batch') {
provider.value = value
}
@@ -198,7 +207,8 @@ async function copyText(value: string, successText: string) {
:hide-footer="isChoosingProvider"
>
<template #icon>
<Xai v-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<LayoutGrid v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
</template>
@@ -209,7 +219,12 @@ async function copyText(value: string, successText: string) {
/>
<div v-else class="flex flex-col gap-4">
<BaseSegmented v-if="!reauthorizing" v-model="mode" :options="modeOptions" class="w-full" />
<BaseSegmented
v-if="!reauthorizing && !isBatch"
v-model="mode"
:options="modeOptions"
class="w-full"
/>
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
@@ -316,7 +331,7 @@ async function copyText(value: string, successText: string) {
:disabled="!canSubmit"
@click="emit('create')"
>
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : '导入' }}
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }}
</BaseButton>
</template>
</BaseModal>
@@ -33,8 +33,8 @@ const overviewItems = computed(() => [
icon: Gauge,
},
{
label: '需处理',
value: formatCount(props.summary.attention),
label: '不可用账号',
value: formatCount(props.summary.unavailable),
caption: '过期 / 禁用 / 封禁',
tone: 'danger',
icon: AlertTriangle,
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
withDefaults(
defineProps<{
@@ -11,10 +12,15 @@ withDefaults(
)
const emit = defineEmits<{
select: [provider: 'openai' | 'xai']
select: [provider: 'openai' | 'xai' | 'batch']
}>()
const providers = [
{
value: 'batch' as const,
label: '批量导入',
icon: LayoutGrid,
},
{
value: 'openai' as const,
label: 'OpenAI',
@@ -273,7 +273,10 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
if (event.type === 'error') {
applyAccountStatus(event.accountStatus)
connectionTestError.value = event.error || '测试连接失败'
appendConnectionTestLog(connectionTestError.value, 'danger')
const detail = event.providerErrorCode || event.providerErrorType
? { code: event.providerErrorCode, type: event.providerErrorType }
: undefined
appendConnectionTestLog(connectionTestError.value, 'danger', detail)
finishConnectionTest('error')
clearConnectionTestRun()
}
@@ -24,7 +24,6 @@ import { useAccountOnboarding } from './useAccountOnboarding'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
const attentionAccountStatuses = new Set(['expired', 'disabled', 'banned'])
const accountStatusSortRank: Record<string, number> = {
active: 0,
quota_exhausted: 1,
@@ -250,15 +249,19 @@ export function useAccountMutations(options: {
+ Number(status === 'quota_exhausted')
- Number(current.status === 'quota_exhausted'),
),
attention: Math.max(
unavailable: Math.max(
0,
options.accountSummary.value.attention
+ Number(attentionAccountStatuses.has(status))
- Number(attentionAccountStatuses.has(current.status)),
options.accountSummary.value.unavailable
+ Number(isUnavailableAccountStatus(status))
- Number(isUnavailableAccountStatus(current.status)),
),
}
}
function isUnavailableAccountStatus(status: string) {
return status === 'expired' || status === 'disabled' || status === 'banned'
}
function sortAccountsByStatus(rows: AccountRow[]) {
if (options.sort.value?.key !== 'status')
return rows
@@ -11,6 +11,12 @@ import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type ImportProvider = 'openai' | 'xai'
interface MixedImportDocument {
provider: ImportProvider
document: Record<string, unknown>
}
export function useAccountOnboarding(options: {
reload: () => Promise<unknown>
@@ -46,10 +52,10 @@ export function useAccountOnboarding(options: {
await creatingAccountAction.run(
async () => {
await importAccountDocument()
await finishCreate(
createForm.value.provider === 'xai' ? 'xAI OAuth 账号已导入' : 'OpenAI 账号已导入',
)
const message = createForm.value.provider === 'batch'
? await importMixedAccountDocument()
: await importAccountDocument()
await finishCreate(message)
},
{ errorText: '导入失败' },
)
@@ -170,6 +176,41 @@ export function useAccountOnboarding(options: {
data,
}))
commitConfigRevision(result.configRevision)
return createForm.value.provider === 'xai' ? 'xAI 账号已导入' : 'OpenAI 账号已导入'
}
async function importMixedAccountDocument() {
const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText))
let importedCount = 0
const failures: string[] = []
for (const entry of documents) {
try {
const expectedConfigRevision = await requireConfigRevision()
const result = await withConflictRefresh(() => importAccounts({
provider: entry.provider,
expectedConfigRevision,
data: entry.document,
}))
commitConfigRevision(result.configRevision)
importedCount += result.importedCount
}
catch (error) {
const message = error instanceof Error && error.message
? error.message
: '导入失败'
failures.push(`${entry.provider === 'xai' ? 'xAI' : 'OpenAI'}:${message}`)
}
}
if (importedCount === 0) {
throw new Error(failures.length > 0 ? `批量导入失败:${failures.join(';')}` : '批量文件没有可导入的账号')
}
if (failures.length > 0) {
return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join(';')}`
}
return `已导入 ${importedCount} 个账号`
}
async function finishCreate(message: string) {
@@ -195,7 +236,7 @@ export function useAccountOnboarding(options: {
() => {
createForm.value = {
...createForm.value,
mode: 'oauth',
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
@@ -255,3 +296,28 @@ function parseImportJson(value: string) {
throw new Error('JSON 格式不正确')
}
}
function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
if (!isRecord(value) || !Array.isArray(value.documents))
throw new Error('批量导入文件必须是 CPR 多平台导出文件')
const documents: MixedImportDocument[] = []
for (const entry of value.documents) {
if (!isRecord(entry))
throw new Error('批量导入文件包含无效的 Provider 文档')
const provider = entry.provider
if (provider !== 'openai' && provider !== 'xai')
throw new Error('批量导入文件包含无效的 Provider 文档')
if (!isRecord(entry.document))
throw new Error('批量导入文件包含无效的 Provider 文档')
documents.push({ provider, document: entry.document })
}
if (documents.length === 0)
throw new Error('批量文件没有可导入的账号文档')
return documents
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -17,7 +17,7 @@ export function useAccountsQuery() {
total: 0,
active: 0,
quotaExhausted: 0,
attention: 0,
unavailable: 0,
})
const query = usePagedQuery({
+8 -1
View File
@@ -98,7 +98,8 @@ export const accountColumns = [
sortable: true,
flex: 1.2,
minWidth: '160px',
format: (_value: unknown, row: AccountRow) => row.usage.lastUsedAtDisplay,
format: (_value: unknown, row: AccountRow) => optionalAccountCell(row.usage.lastUsedAtDisplay),
emptyText: '',
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
},
{
@@ -108,6 +109,8 @@ export const accountColumns = [
sortKey: 'expiresAt',
flex: 1.2,
minWidth: '160px',
format: value => optionalAccountCell(value),
emptyText: '',
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
},
{
@@ -201,3 +204,7 @@ function accountProviderLabel(value?: string | null) {
return 'xAI'
return value || '—'
}
function optionalAccountCell(value: unknown) {
return value === '—' || value === '-' ? '' : value
}
@@ -39,8 +39,8 @@ const emptyCards: DashboardSummary['cards'] = {
credentials: {
total: '0',
totalValue: 0,
enabled: '0',
enabledValue: 0,
available: '0',
availableValue: 0,
unavailable: '0',
unavailableValue: 0,
},
@@ -147,9 +147,9 @@ function metricCards(
tone: 'normal',
details: [
{
label: '启用',
value: credentials.enabled,
tone: credentials.enabledValue > 0 ? 'success' : 'normal',
label: '可用',
value: credentials.available,
tone: credentials.availableValue > 0 ? 'success' : 'normal',
},
{
label: '不可用',
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowRight, Cat, CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
import { ArrowRight, CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -58,7 +58,12 @@ function togglePasswordVisible(): void {
<header class="flex min-w-0 items-center justify-between gap-4.5 max-[560px]:gap-3.5">
<div class="flex min-w-0 items-center gap-3">
<BaseMotionIcon aria-hidden="true" variant="brand" class="login-logo">
<Cat :size="20" :stroke-width="2.1" />
<img
src="/favicon.svg"
alt=""
class="block size-8 select-none"
draggable="false"
>
</BaseMotionIcon>
<span class="grid min-w-0 gap-1">
<strong
+2 -2
View File
@@ -93,7 +93,7 @@ async function handleSubmit(): Promise<void> {
--login-form-bg-c: color-mix(in srgb, var(--cp-bg-muted) 78%, var(--cp-info-bg));
--login-form-shadow: color-mix(in srgb, var(--cp-text-primary) 20%, transparent);
--login-form-line: color-mix(in srgb, var(--cp-info-border) 54%, transparent);
--login-logo-bg: color-mix(in srgb, var(--cp-info-bg) 74%, var(--cp-bg-surface));
--login-logo-bg: var(--cp-brand-mark-bg);
--login-logo-text: var(--cp-accent-primary);
--login-title: var(--cp-text-primary);
--login-brand-title: var(--cp-text-strong);
@@ -159,7 +159,7 @@ async function handleSubmit(): Promise<void> {
--login-form-bg-c: color-mix(in srgb, var(--cp-bg-page) 90%, transparent);
--login-form-shadow: color-mix(in srgb, var(--cp-bg-dark) 70%, transparent);
--login-form-line: color-mix(in srgb, var(--cp-info-text) 66%, transparent);
--login-logo-bg: color-mix(in srgb, var(--cp-bg-surface) 84%, transparent);
--login-logo-bg: var(--cp-brand-mark-bg);
--login-logo-text: var(--cp-info-text);
--login-title: var(--cp-text-strong);
--login-brand-title: var(--cp-white);
@@ -57,7 +57,7 @@ function latencyDisplay(value: unknown) {
<template v-if="record">
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
<div class="flex flex-wrap items-center gap-3">
<UsageStatusCodeBadge :status-code="record.statusCode" />
<UsageStatusCodeBadge :status-code="record.upstreamStatusCode" />
<span class="text-[12px] font-bold text-(--cp-text-secondary)">
客户端 {{ display(record.clientStatusCode) }}
</span>
@@ -88,9 +88,9 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<template #statusCode="{ row }">
<template #upstreamStatusCode="{ row }">
<UsageStatusCodeBadge
:status-code="typeof row.statusCode === 'number' ? row.statusCode : null"
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
/>
</template>
<template #failureClass="{ row }">
@@ -9,6 +9,10 @@ const props = defineProps<{
summary: Awaited<ReturnType<typeof getUsageRecordSummary>>
}>()
function averageLatencyDisplay(value: string) {
return !value || value === '—' || value === '-' ? '0 ms' : value
}
const items = computed(() => [
{
key: 'requests',
@@ -38,7 +42,7 @@ const items = computed(() => [
key: 'latency',
label: '平均耗时',
icon: Timer,
value: props.summary.averageLatencyMs,
value: averageLatencyDisplay(props.summary.averageLatencyMs),
detail: '成功请求平均值',
tone: 'text-(--cp-normal-text) bg-(--cp-normal-bg)',
},
@@ -247,7 +247,7 @@ function emptySummary() {
cachedTokens: '0',
cacheWriteTokens: '0',
totalTokens: '0',
averageLatencyMs: '—',
averageLatencyMs: '0 ms',
}
return summary
}
+1 -1
View File
@@ -108,7 +108,7 @@ export const opsErrorColumns = [
cellClass:
'whitespace-nowrap font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
},
{ key: 'statusCode', label: '状态码', width: '96px', align: 'center' as const },
{ key: 'upstreamStatusCode', label: '上游状态', width: '96px', align: 'center' as const },
{
key: 'failureClass',
label: '失败分类',
+1 -1
View File
@@ -1 +1 @@
version: "3.0.0"
version: "3.0.7"
+5
View File
@@ -19,9 +19,13 @@ description: Codex Proxy RS 仓库开发指南。Use when changing or auditing i
- `gateway-api` 只做协议适配;`gateway-core` 编排;Provider 独占 credential、catalog 与 transport;`gateway-store` 实现持久化端口。
- Provider 每次调用只选择一个 credential,不隐藏换号、业务 retry 或跨 Provider fallback。
- PostgreSQL 是持久化权威;Redis 只保存可恢复协调状态和 OAuth pending flow。
- 当前只有 `openai` 与 `xai` 两个 Provider;账号归属 Provider,不存在 Provider Instance 层。
- OpenAI 的 OAuth 与 Agent Identity credential 由 `providers/openai` 独占解析;账号导出使用 CPR 文档,Agent Identity
只输出运行所需身份材料,不伪造 OAuth 字段。
- 测试放在各 package 的 `tests/`。禁止在生产 `src/` 写 test-only 代码。
- Vue 使用 `<script setup lang="ts">`、现有基础组件和主题 token。
- README 面向使用者,保持简短;长期架构只写入 `docs/architecture.md`。
- 完整 HTTP/Admin 路由与敏感导入/导出格式写入 `docs/api.md`。
- 不添加兼容 shim、重复状态机、第二套配置或补丁式旁路。
## 工作流
@@ -54,4 +58,5 @@ docker compose -f deploy/compose.yaml config --quiet
- 提交前检查 `git status --short`、cached/unstaged diff 和 `git diff --check`。
- 提交带 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布以 `release/version.yaml` 和带注释的 `vX.Y.Z` tag 为准。
- 正式发布使用 `release/publish <version>`,不要手工绕过发布脚本。
- 发布完成后核对远端 `main`、tag、Actions、Release asset 与 GHCR。
@@ -32,19 +32,19 @@
- Core 冻结 RuntimeSnapshot、RoutePlan、retry/fallback 与 downstream commit 边界。
- Provider 每次 `execute` 只选择一个 credential 并返回 cold canonical stream。
- 换号只由 Core 在下游 commit 前按重放安全性决定。
- fallback 只允许同 instance 账号和同 Provider kind instance;不跨 Provider kind。
- fallback 只允许同一 Provider kind 内的账号;不跨 Provider,也不存在 Provider Instance 层。
- OpenAI continuation 为 native → replay owner → replay any;xAI 使用客户端完整历史。
- Provider wire profile 以配置为启动基线,并由共享运行时状态统一发布。OpenAI CLI 读取 `@openai/codex`、Desktop 读取官方 appcast、xAI CLI 读取 `@xai-official/grok`;发现新版本后自动更新对应版本字段,所有消费边界不得维护独立常量。
- `downstream_committed_at` 是不可撤回交付承诺,不是首字节已经写达的证明。
## 存储
- PostgreSQL 只有 `0001_initial.sql` 定义的八张终态业务表。
- PostgreSQL 只有 `0001_initial.sql` 定义的七张终态业务表。
- `config_revision` 只用于会改变调度快照或安全配置的管理 mutation。
- quota、cooldown、catalog generation、自动 refresh 不推进全局 revision。
- refresh 只推进账号 `credential_revision`;Redis cooldown 是可丢失热缓存。
- OAuth pending 使用 Provider 域隔离 SHA-256 key、固定三字段 Hash 和原子一次消费。
- secret 不进入日志、Debug、fixture、文档或 audit details。
- 真实 secret 不进入日志、Debug、fixture、文档或 audit details;测试只能使用合成值。
## 前端