mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6125b10276 | ||
|
|
071271f003 | ||
|
|
13a9df2e07 | ||
|
|
37a5a6702b | ||
|
|
afb68e97d6 | ||
|
|
93b9741af2 | ||
|
|
013d592454 | ||
|
|
0f56e9b80c | ||
|
|
6ec5ecb9ac | ||
|
|
460d439351 | ||
|
|
f39406dd80 | ||
|
|
9de8e75238 | ||
|
|
fee34b6bd1 | ||
|
|
cbe8699322 | ||
|
|
066e5551f8 | ||
|
|
1d73b736a9 |
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,15 @@ pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) ->
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
return (StatusCode::OK, Json(json!({ "models": models }))).into_response();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"models": models,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(
|
||||
@@ -209,6 +217,7 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
fn reasoning_effort_description(effort: &str) -> &'static str {
|
||||
match effort {
|
||||
"none" => "No reasoning",
|
||||
"minimal" => "Minimal reasoning",
|
||||
"low" => "Fast responses with lighter reasoning",
|
||||
"medium" => "Balances speed and reasoning depth for everyday tasks",
|
||||
"high" => "Greater reasoning depth for complex problems",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ impl ExecutionService for ModelsExecution {
|
||||
)
|
||||
.with_reasoning(
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high"].map(str::to_owned).to_vec(),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
@@ -138,6 +140,54 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
assert_eq!(model["context_window"], 500_000);
|
||||
assert_eq!(model["apply_patch_tool_type"], "freeform");
|
||||
assert_eq!(model["service_tiers"], serde_json::json!([]));
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"],
|
||||
serde_json::json!([
|
||||
{
|
||||
"effort": "low",
|
||||
"description": "Fast responses with lighter reasoning"
|
||||
},
|
||||
{
|
||||
"effort": "medium",
|
||||
"description": "Balances speed and reasoning depth for everyday tasks"
|
||||
},
|
||||
{
|
||||
"effort": "high",
|
||||
"description": "Greater reasoning depth for complex problems"
|
||||
},
|
||||
{
|
||||
"effort": "xhigh",
|
||||
"description": "Extra high reasoning depth for complex problems"
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_with_provider_profiles_should_keep_the_openai_list_contract() {
|
||||
let response = api_router(ModelsExecution::with_profiles())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models"))
|
||||
.await
|
||||
.expect("list compatible models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read compatible models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"object": value["object"],
|
||||
"data": value["data"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id":"model-a","object":"model","created":1700000000_i64,"owned_by":"gateway"},
|
||||
{"id":"model-b","object":"model","created":1700000000_i64,"owned_by":"gateway"}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) async fn download_file(
|
||||
.map_err(|error| super::internal(format!("failed to create download file: {error}")))?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded = 0_u64;
|
||||
let mut last_percent = 0_u8;
|
||||
let mut next_progress = 10_u8;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| upstream(format!("download stream failed: {error}")))?;
|
||||
downloaded = downloaded.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
|
||||
@@ -59,9 +59,17 @@ pub(crate) async fn download_file(
|
||||
file.write_all(&chunk)
|
||||
.map_err(|error| super::internal(format!("failed to write download: {error}")))?;
|
||||
let percent = download_percent(downloaded, expected_size);
|
||||
if percent > last_percent {
|
||||
last_percent = percent;
|
||||
events.emit_progress(operation_id, "downloading release archive", percent);
|
||||
if percent >= next_progress || downloaded == expected_size {
|
||||
events.emit_progress(
|
||||
operation_id,
|
||||
&format!(
|
||||
"已下载 {} / {} ({percent}%)",
|
||||
format_bytes(downloaded),
|
||||
format_bytes(expected_size)
|
||||
),
|
||||
percent,
|
||||
);
|
||||
next_progress = percent.saturating_add(10);
|
||||
}
|
||||
}
|
||||
file.sync_all()
|
||||
@@ -155,3 +163,15 @@ fn download_percent(downloaded: u64, total: u64) -> u8 {
|
||||
)
|
||||
.unwrap_or(100)
|
||||
}
|
||||
|
||||
pub(crate) fn format_bytes(bytes: u64) -> String {
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = KIB * 1024;
|
||||
if bytes >= MIB {
|
||||
return format!("{:.1} MiB", bytes as f64 / MIB as f64);
|
||||
}
|
||||
if bytes >= KIB {
|
||||
return format!("{:.1} KiB", bytes as f64 / KIB as f64);
|
||||
}
|
||||
format!("{bytes} B")
|
||||
}
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -57,23 +57,25 @@ pub use transport::{
|
||||
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
|
||||
GrokBillingTransportResponse, GrokCanonicalDecoder, GrokCatalogApiBackend,
|
||||
GrokCatalogCapabilities, GrokCatalogCapabilityEvidence, GrokCatalogLimits, GrokCatalogMetadata,
|
||||
GrokCatalogModel, GrokClientIdentity, GrokCompactionDecodeError, GrokCompactionRequest,
|
||||
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokCredentialFeedbackFuture,
|
||||
GrokDnsResolutionError, GrokDnsResolutionPlan, GrokDnsResolutionPolicy, GrokEndpointPolicy,
|
||||
GrokHeader, GrokHeaderValue, GrokInferenceChunkStream, GrokInferenceRequest,
|
||||
GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError,
|
||||
GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokModelCatalogClient,
|
||||
GrokModelCatalogError, GrokModelCatalogRequest, GrokModelCatalogSession,
|
||||
GrokModelCatalogSessionError, GrokModelCatalogSnapshot, GrokModelCatalogTransport,
|
||||
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
|
||||
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokProviderConfigError,
|
||||
GrokProviderTransport, GrokRequestEncodeError, GrokReqwestTransportBuildError,
|
||||
GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError,
|
||||
GrokSessionLeaseGuard, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError,
|
||||
GrokSessionSelectorFuture, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES,
|
||||
OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport,
|
||||
ReqwestOAuthTransport, SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers,
|
||||
grok_billing_breakdown, parse_grok_billing, parse_grok_model_catalog,
|
||||
GrokCatalogModel, GrokCatalogReasoningEffort, GrokClientIdentity, GrokCompactionDecodeError,
|
||||
GrokCompactionRequest, GrokCompactionSummaryDecoder, GrokCredentialFailure,
|
||||
GrokCredentialFeedbackFuture, GrokDnsResolutionError, GrokDnsResolutionPlan,
|
||||
GrokDnsResolutionPolicy, GrokEndpointPolicy, GrokHeader, GrokHeaderValue,
|
||||
GrokInferenceChunkStream, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
|
||||
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
|
||||
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
|
||||
GrokInferenceTransportMetrics, GrokModelCatalogClient, GrokModelCatalogError,
|
||||
GrokModelCatalogRequest, GrokModelCatalogSession, GrokModelCatalogSessionError,
|
||||
GrokModelCatalogSnapshot, GrokModelCatalogTransport, GrokModelCatalogTransportError,
|
||||
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
|
||||
GrokModelCatalogTransportResponse, GrokProviderConfigError, GrokProviderTransport,
|
||||
GrokRequestEncodeError, GrokReqwestTransportBuildError, GrokResponsesRequest,
|
||||
GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError, GrokSessionLeaseGuard,
|
||||
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
|
||||
MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OfficialGrokEndpointPolicy,
|
||||
ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport,
|
||||
SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers, grok_billing_breakdown,
|
||||
parse_grok_billing, parse_grok_model_catalog,
|
||||
};
|
||||
|
||||
/// xAI 初始化后交给组装根的最小能力集。
|
||||
@@ -99,6 +101,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 +196,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};
|
||||
|
||||
@@ -427,7 +430,9 @@ fn default_grok_model_presentation() -> ModelPresentation {
|
||||
)
|
||||
.with_reasoning(
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high"].map(str::to_owned).to_vec(),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
@@ -441,17 +446,35 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
);
|
||||
let reasoning_evidence = model.capabilities().reasoning_effort();
|
||||
let catalog_reasoning_efforts = model
|
||||
.capabilities()
|
||||
.reasoning_efforts()
|
||||
.iter()
|
||||
.map(|effort| effort.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let catalog_default_reasoning = model
|
||||
.capabilities()
|
||||
.default_reasoning_effort()
|
||||
.map(|effort| effort.as_str().to_owned());
|
||||
let (default_reasoning, reasoning_efforts) = match reasoning_evidence {
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative if !catalog_reasoning_efforts.is_empty() => (
|
||||
catalog_default_reasoning.or_else(|| catalog_reasoning_efforts.first().cloned()),
|
||||
catalog_reasoning_efforts,
|
||||
),
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative => (
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high"].map(str::to_owned).to_vec(),
|
||||
catalog_default_reasoning.or_else(|| Some("medium".to_owned())),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
),
|
||||
GrokCatalogCapabilityEvidence::DeclaredUnsupported => {
|
||||
(Some("none".to_owned()), vec!["none".to_owned()])
|
||||
}
|
||||
GrokCatalogCapabilityEvidence::Unknown if known_grok_4_5 => (
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high"].map(str::to_owned).to_vec(),
|
||||
catalog_default_reasoning.or_else(|| Some("medium".to_owned())),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
),
|
||||
GrokCatalogCapabilityEvidence::Unknown => (None, Vec::new()),
|
||||
};
|
||||
@@ -950,17 +973,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 +1294,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 +1390,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(
|
||||
|
||||
@@ -589,11 +589,42 @@ pub enum GrokCatalogApiBackend {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Grok 官方目录允许声明的 reasoning effort。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GrokCatalogReasoningEffort {
|
||||
None,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Xhigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl GrokCatalogReasoningEffort {
|
||||
/// 返回 Responses API 使用的规范值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::Xhigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grok 目录中允许进入控制面的能力证据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GrokCatalogCapabilities {
|
||||
responses_api: GrokCatalogCapabilityEvidence,
|
||||
reasoning_effort: GrokCatalogCapabilityEvidence,
|
||||
reasoning_efforts: Vec<GrokCatalogReasoningEffort>,
|
||||
default_reasoning_effort: Option<GrokCatalogReasoningEffort>,
|
||||
backend_search: GrokCatalogCapabilityEvidence,
|
||||
streaming_tool_calls: GrokCatalogCapabilityEvidence,
|
||||
api_backend: Option<GrokCatalogApiBackend>,
|
||||
@@ -612,6 +643,18 @@ impl GrokCatalogCapabilities {
|
||||
self.reasoning_effort
|
||||
}
|
||||
|
||||
/// 返回上游声明的可选 reasoning effort,顺序与目录一致。
|
||||
#[must_use]
|
||||
pub fn reasoning_efforts(&self) -> &[GrokCatalogReasoningEffort] {
|
||||
&self.reasoning_efforts
|
||||
}
|
||||
|
||||
/// 返回上游声明或菜单推导出的默认 reasoning effort。
|
||||
#[must_use]
|
||||
pub const fn default_reasoning_effort(&self) -> Option<GrokCatalogReasoningEffort> {
|
||||
self.default_reasoning_effort
|
||||
}
|
||||
|
||||
/// 返回后端 search 支持证据。
|
||||
#[must_use]
|
||||
pub const fn backend_search(&self) -> GrokCatalogCapabilityEvidence {
|
||||
@@ -850,6 +893,10 @@ struct GrokModelWire {
|
||||
alias = "supports_reasoning_effort"
|
||||
)]
|
||||
supports_reasoning_effort: Option<bool>,
|
||||
#[serde(rename = "reasoningEffort", alias = "reasoning_effort")]
|
||||
reasoning_effort: Option<GrokCatalogReasoningEffort>,
|
||||
#[serde(default, rename = "reasoningEfforts", alias = "reasoning_efforts")]
|
||||
reasoning_efforts: Vec<GrokReasoningEffortOptionWire>,
|
||||
#[serde(rename = "supportsBackendSearch", alias = "supports_backend_search")]
|
||||
supports_backend_search: Option<bool>,
|
||||
#[serde(rename = "streamToolCalls", alias = "stream_tool_calls")]
|
||||
@@ -857,6 +904,26 @@ struct GrokModelWire {
|
||||
hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum GrokReasoningEffortOptionWire {
|
||||
Bare(GrokCatalogReasoningEffort),
|
||||
Detailed {
|
||||
value: GrokCatalogReasoningEffort,
|
||||
#[serde(default)]
|
||||
default: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl GrokReasoningEffortOptionWire {
|
||||
fn into_parts(self) -> (GrokCatalogReasoningEffort, bool) {
|
||||
match self {
|
||||
Self::Bare(value) => (value, false),
|
||||
Self::Detailed { value, default } => (value, default),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析唯一受支持的 Grok 官方完整快照。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -922,15 +989,36 @@ fn normalize_model(wire: GrokModelWire) -> Result<GrokCatalogModel, GrokModelCat
|
||||
.api_backend
|
||||
.filter(|backend| *backend != GrokCatalogApiBackend::Unknown);
|
||||
let responses_api = responses_evidence(wire.supported_in_api, api_backend);
|
||||
let mut menu_default = None;
|
||||
let reasoning_efforts = wire
|
||||
.reasoning_efforts
|
||||
.into_iter()
|
||||
.map(|option| {
|
||||
let (effort, is_default) = option.into_parts();
|
||||
if is_default && menu_default.is_none() {
|
||||
menu_default = Some(effort);
|
||||
}
|
||||
effort
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let default_reasoning_effort = wire
|
||||
.reasoning_effort
|
||||
.or(menu_default)
|
||||
.or_else(|| reasoning_efforts.first().copied());
|
||||
let reasoning_effort = if reasoning_efforts.is_empty() {
|
||||
GrokCatalogCapabilityEvidence::from_wire(wire.supports_reasoning_effort)
|
||||
} else {
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative
|
||||
};
|
||||
|
||||
Ok(GrokCatalogModel {
|
||||
request_model,
|
||||
display_name: wire.name,
|
||||
capabilities: GrokCatalogCapabilities {
|
||||
responses_api,
|
||||
reasoning_effort: GrokCatalogCapabilityEvidence::from_wire(
|
||||
wire.supports_reasoning_effort,
|
||||
),
|
||||
reasoning_effort,
|
||||
reasoning_efforts,
|
||||
default_reasoning_effort,
|
||||
backend_search: GrokCatalogCapabilityEvidence::from_wire(wire.supports_backend_search),
|
||||
streaming_tool_calls: GrokCatalogCapabilityEvidence::from_wire(wire.stream_tool_calls),
|
||||
api_backend,
|
||||
|
||||
@@ -21,10 +21,10 @@ pub use catalog::{
|
||||
GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, GrokBillingTransportError,
|
||||
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
|
||||
GrokCatalogApiBackend, GrokCatalogCapabilities, GrokCatalogCapabilityEvidence,
|
||||
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokModelCatalogClient,
|
||||
GrokModelCatalogError, GrokModelCatalogRequest, GrokModelCatalogSession,
|
||||
GrokModelCatalogSessionError, GrokModelCatalogSnapshot, GrokModelCatalogTransport,
|
||||
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
|
||||
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokCatalogReasoningEffort,
|
||||
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
|
||||
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
|
||||
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
|
||||
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, MAX_GROK_BILLING_BYTES,
|
||||
MAX_GROK_MODEL_CATALOG_BYTES, parse_grok_billing, parse_grok_model_catalog,
|
||||
};
|
||||
@@ -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");
|
||||
@@ -1740,7 +1827,7 @@ async fn provider_compiles_realtime_catalog_capabilities() {
|
||||
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
|
||||
assert_eq!(
|
||||
presentation.supported_reasoning_efforts(),
|
||||
["low", "medium", "high"]
|
||||
["low", "medium", "high", "xhigh"]
|
||||
);
|
||||
assert_eq!(presentation.context_window_tokens(), Some(1_000_000));
|
||||
assert!(presentation.agent_tools());
|
||||
|
||||
@@ -261,6 +261,16 @@ fn official_fixture_should_use_actual_model_and_whitelisted_metadata() {
|
||||
(
|
||||
model.capabilities().responses_api(),
|
||||
model.capabilities().reasoning_effort(),
|
||||
model
|
||||
.capabilities()
|
||||
.reasoning_efforts()
|
||||
.iter()
|
||||
.map(|effort| effort.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
model
|
||||
.capabilities()
|
||||
.default_reasoning_effort()
|
||||
.map(|effort| effort.as_str()),
|
||||
model.capabilities().backend_search(),
|
||||
model.capabilities().streaming_tool_calls(),
|
||||
),
|
||||
@@ -278,6 +288,8 @@ fn official_fixture_should_use_actual_model_and_whitelisted_metadata() {
|
||||
(
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
vec!["low", "medium", "high", "xhigh"],
|
||||
Some("medium"),
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
),
|
||||
@@ -335,6 +347,35 @@ fn missing_capability_fields_should_remain_unknown() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_menu_should_be_capability_evidence_without_legacy_flag() {
|
||||
let snapshot = parse_grok_model_catalog(
|
||||
br#"{"object":"list","data":[{"id":"grok-reasoning","reasoningEfforts":[{"value":"xhigh","default":true},"low"]}]}"#,
|
||||
None,
|
||||
)
|
||||
.expect("reasoning effort menu");
|
||||
let capabilities = snapshot.models()[0].capabilities();
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
capabilities.reasoning_effort(),
|
||||
capabilities
|
||||
.reasoning_efforts()
|
||||
.iter()
|
||||
.map(|effort| effort.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
capabilities
|
||||
.default_reasoning_effort()
|
||||
.map(|effort| effort.as_str()),
|
||||
),
|
||||
(
|
||||
GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
vec!["xhigh", "low"],
|
||||
Some("xhigh"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_id_should_take_priority_over_catalog_id() {
|
||||
let snapshot = parse_grok_model_catalog(
|
||||
|
||||
+7
@@ -12,6 +12,13 @@
|
||||
"apiBackend": "responses",
|
||||
"supportedInApi": true,
|
||||
"supportsReasoningEffort": true,
|
||||
"reasoningEffort": "medium",
|
||||
"reasoningEfforts": [
|
||||
"low",
|
||||
{ "value": "medium", "label": "Balanced", "default": true },
|
||||
"high",
|
||||
{ "id": "extended", "value": "xhigh", "label": "Extended" }
|
||||
],
|
||||
"supportsBackendSearch": true,
|
||||
"streamToolCalls": true,
|
||||
"hidden": false,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,19 +35,30 @@ const configPath = computed(() =>
|
||||
? '%userprofile%\\.codex\\config.toml'
|
||||
: '~/.codex/config.toml',
|
||||
)
|
||||
const authPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex\\auth.json' : '~/.codex/auth.json',
|
||||
const tokenPath = computed(() =>
|
||||
activePlatform.value === 'windows'
|
||||
? '%userprofile%\\.codex\\codex-proxy.token'
|
||||
: '~/.codex/codex-proxy.token',
|
||||
)
|
||||
const codexAuthJson = computed(() => JSON.stringify({ OPENAI_API_KEY: keyValue.value }, null, 2))
|
||||
const defaultModel = computed(() =>
|
||||
props.apiKey?.providerKind?.trim().toLowerCase() === 'xai' ? 'grok-4.5' : 'gpt-5.5',
|
||||
)
|
||||
const providerAuthToml = computed(() =>
|
||||
activePlatform.value === 'windows'
|
||||
? `[model_providers.OpenAI.auth]
|
||||
command = "powershell.exe"
|
||||
args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Get-Content -Raw (Join-Path (Join-Path $env:USERPROFILE '.codex') 'codex-proxy.token')"]`
|
||||
: `[model_providers.OpenAI.auth]
|
||||
command = "sh"
|
||||
args = ["-c", 'cat "$HOME/.codex/codex-proxy.token"']`,
|
||||
)
|
||||
|
||||
const codexConfigToml = computed(
|
||||
() => `model_provider = "OpenAI"
|
||||
model = "${defaultModel.value}"
|
||||
review_model = "${defaultModel.value}"
|
||||
model_reasoning_effort = "xhigh"
|
||||
service_tier = "default"
|
||||
disable_response_storage = true
|
||||
network_access = "enabled"
|
||||
windows_wsl_setup_acknowledged = true
|
||||
@@ -56,7 +67,8 @@ windows_wsl_setup_acknowledged = true
|
||||
name = "OpenAI"
|
||||
base_url = "${props.apiBaseUrl}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
|
||||
${providerAuthToml.value}
|
||||
|
||||
[features]
|
||||
goals = true`,
|
||||
@@ -64,7 +76,7 @@ goals = true`,
|
||||
|
||||
const visibleFiles = computed(() => [
|
||||
{ path: configPath.value, content: codexConfigToml.value },
|
||||
{ path: authPath.value, content: codexAuthJson.value },
|
||||
{ path: tokenPath.value, content: keyValue.value },
|
||||
])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.0.4"
|
||||
version: "3.0.9"
|
||||
|
||||
Reference in New Issue
Block a user