Compare commits

...
12 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
31 changed files with 1345 additions and 239 deletions
@@ -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,
})
}
@@ -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())
}
}
@@ -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")
}
@@ -232,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(
@@ -258,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
@@ -294,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"));
}
@@ -306,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,
@@ -321,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,
@@ -329,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(())
}
}
@@ -515,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>,
@@ -204,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;
+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))?;
+8 -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,6 +100,7 @@ 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();
@@ -193,6 +195,7 @@ pub async fn initialize(
catalog: Arc::clone(&catalog),
runtime_policy,
},
cli_release_status,
));
let worker_contributions = provider::worker_contributions(
refresh,
+74 -26
View File
@@ -21,7 +21,10 @@ use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendStat
use gateway_core::error::{
ClientVisibleUpstreamError, ContinuationFailure, ProviderError, ProviderErrorKind,
};
use gateway_core::event::{GatewayEvent, ProviderEvent, ProviderResponseObservation, ResponseMeta};
use gateway_core::event::{
GatewayEvent, ProviderEvent, ProviderResponseMetadata, ProviderResponseObservation,
ProviderResponseTimings, ResponseMeta,
};
use gateway_core::operation::{
Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState,
};
@@ -52,9 +55,9 @@ 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};
@@ -950,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,
@@ -1277,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();
@@ -1383,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(
@@ -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,47 +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);
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),
)
})
}
}
@@ -406,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) {
@@ -448,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,
@@ -581,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)?));
@@ -626,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 {
@@ -647,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<_>>(),
@@ -657,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,
@@ -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(),
})
}
}
@@ -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 {
@@ -31,13 +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, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, 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};
@@ -48,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": [{
@@ -246,6 +258,7 @@ impl GrokSessionSelector for StubSelector {
enum InferenceMode {
Success,
SuccessWithMetrics(GrokInferenceTransportMetrics),
SuccessBody(Vec<u8>),
Error(GrokInferenceTransportError),
StreamError(GrokInferenceTransportError),
@@ -266,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),
@@ -309,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,
@@ -719,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(
@@ -744,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()
@@ -753,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");
@@ -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));
}
}
@@ -535,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 {
@@ -550,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 {
+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" />
+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"
+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;
+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
}
@@ -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);
@@ -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
@@ -1 +1 @@
version: "3.0.4"
version: "3.0.7"