mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39406dd80 | ||
|
|
9de8e75238 | ||
|
|
fee34b6bd1 | ||
|
|
cbe8699322 | ||
|
|
066e5551f8 | ||
|
|
1d73b736a9 |
@@ -206,6 +206,7 @@ struct SystemUpdateEventView {
|
||||
step: Option<String>,
|
||||
message: String,
|
||||
terminal: bool,
|
||||
progress_percent: Option<u8>,
|
||||
at: String,
|
||||
}
|
||||
|
||||
@@ -408,6 +409,7 @@ impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
step: event.step,
|
||||
message: event.message,
|
||||
terminal: event.terminal,
|
||||
progress_percent: event.progress_percent,
|
||||
at: event.occurred_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -421,6 +423,7 @@ impl SystemUpdateEventView {
|
||||
"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,80 @@ 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_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#""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;
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -118,7 +118,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 +213,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 +330,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.0.4"
|
||||
version: "3.0.5"
|
||||
|
||||
Reference in New Issue
Block a user