mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6125b10276 | ||
|
|
071271f003 | ||
|
|
13a9df2e07 | ||
|
|
37a5a6702b | ||
|
|
afb68e97d6 | ||
|
|
93b9741af2 | ||
|
|
013d592454 | ||
|
|
0f56e9b80c | ||
|
|
6ec5ecb9ac | ||
|
|
460d439351 | ||
|
|
f39406dd80 | ||
|
|
9de8e75238 | ||
|
|
fee34b6bd1 | ||
|
|
cbe8699322 | ||
|
|
066e5551f8 | ||
|
|
1d73b736a9 | ||
|
|
8ae6635b9c | ||
|
|
574f0d580e | ||
|
|
0879515ee8 | ||
|
|
be40783454 |
Generated
+1
@@ -1152,6 +1152,7 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
||||
@@ -27,6 +27,7 @@ thiserror = "2.0.18"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1.0.150", features = ["preserve_order"] }
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -29,7 +29,6 @@ pub enum AccountStatus {
|
||||
QuotaExhausted,
|
||||
Disabled,
|
||||
Banned,
|
||||
Attention,
|
||||
}
|
||||
|
||||
/// 账号列表排序字段。
|
||||
@@ -184,7 +183,7 @@ pub struct AccountSummary {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub attention: u64,
|
||||
pub unavailable: u64,
|
||||
}
|
||||
|
||||
/// 账号启停写入命令。
|
||||
@@ -222,6 +221,8 @@ pub enum AccountConnectionTestEvent {
|
||||
},
|
||||
Failed {
|
||||
message: String,
|
||||
provider_error_code: Option<String>,
|
||||
provider_error_type: Option<String>,
|
||||
account_status: AccountStatus,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -718,7 +718,8 @@ pub struct OpsError {
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub failure_kind: String,
|
||||
pub status_code: Option<u16>,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub client_response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
|
||||
@@ -10,7 +10,6 @@ use gateway_core::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeRequest},
|
||||
},
|
||||
error::GatewayErrorKind,
|
||||
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
|
||||
};
|
||||
|
||||
@@ -454,12 +453,9 @@ impl AccountsService for DefaultAccountsService {
|
||||
}))
|
||||
.collect(),
|
||||
Err(error) => vec![AccountConnectionTestEvent::Failed {
|
||||
message: match error.kind() {
|
||||
GatewayErrorKind::InvalidRequest
|
||||
| GatewayErrorKind::Unsupported
|
||||
| GatewayErrorKind::ModelNotFound => error.safe_message().to_owned(),
|
||||
_ => "Provider connection test failed".to_owned(),
|
||||
},
|
||||
message: error.client_message().to_owned(),
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
|
||||
account_status: status,
|
||||
}],
|
||||
}
|
||||
@@ -485,8 +481,7 @@ fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> Accoun
|
||||
{
|
||||
AccountStatus::Expired
|
||||
}
|
||||
crate::model::accounts::AccountAvailability::Ready => AccountStatus::Active,
|
||||
_ => AccountStatus::Attention,
|
||||
_ => AccountStatus::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{StreamExt as _, future::BoxFuture};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::{OpaqueProviderData, ProviderAccountId},
|
||||
probe::{AccountProbe, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::GatewayError,
|
||||
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
|
||||
operation::{GenerateRequest, Operation, ProtocolPayload},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
|
||||
@@ -17,9 +18,9 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountAvailability, AccountListQuery, AccountPage, AccountRecord, AccountSummary,
|
||||
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, DeleteAccounts,
|
||||
SetAccountEnabled,
|
||||
AccountAvailability, AccountConnectionTestEvent, AccountListQuery, AccountPage,
|
||||
AccountRecord, AccountSummary, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
@@ -48,6 +49,7 @@ use gateway_admin::{
|
||||
},
|
||||
},
|
||||
};
|
||||
use serde_json::{Map, json};
|
||||
|
||||
pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
|
||||
|
||||
@@ -159,10 +161,22 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
|
||||
fn connection_test_operation(
|
||||
&self,
|
||||
_: &gateway_core::routing::UpstreamModelId,
|
||||
_: &str,
|
||||
model: &gateway_core::routing::UpstreamModelId,
|
||||
input: &str,
|
||||
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!(model.as_str())),
|
||||
("input".to_owned(), json!(input)),
|
||||
("stream".to_owned(), json!(true)),
|
||||
("store".to_owned(), json!(false)),
|
||||
]),
|
||||
)
|
||||
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid))?;
|
||||
Ok(Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
payload,
|
||||
)))
|
||||
}
|
||||
|
||||
fn dashboard_wire_profile(
|
||||
@@ -395,7 +409,7 @@ impl AccountStore for FakeAccountStore {
|
||||
total: 1,
|
||||
active: 1,
|
||||
quota_exhausted: 0,
|
||||
attention: 0,
|
||||
unavailable: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -686,6 +700,37 @@ fn provider_registry_should_reject_duplicate_kind() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_test_should_preserve_safe_provider_error_details() {
|
||||
let provider = FakeProviderAdmin::new("xai", events());
|
||||
let store = FakeAccountStore::new("xai", events());
|
||||
let services =
|
||||
accounts_service_with_probe(provider, store, Arc::new(FailingAccountProbe)).await;
|
||||
|
||||
let events = services
|
||||
.accounts()
|
||||
.test_connection(
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
)
|
||||
.await
|
||||
.expect("connection test stream")
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
events.last(),
|
||||
Some(AccountConnectionTestEvent::Failed {
|
||||
message,
|
||||
provider_error_code: Some(code),
|
||||
provider_error_type: Some(error_type),
|
||||
..
|
||||
}) if message == "included usage exhausted"
|
||||
&& code == "usage_exhausted"
|
||||
&& error_type == "invalid_request_error"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_export_should_pass_store_loaded_timestamps_and_material_to_provider() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
@@ -1005,12 +1050,20 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
async fn accounts_service(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
) -> AdminServices {
|
||||
accounts_service_with_probe(provider, store, Arc::new(SuccessfulAccountProbe)).await
|
||||
}
|
||||
|
||||
async fn accounts_service_with_probe(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.provider(provider)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
.probe(probe)
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
@@ -1030,6 +1083,29 @@ impl AccountProbe for SuccessfulAccountProbe {
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingAccountProbe;
|
||||
|
||||
impl AccountProbe for FailingAccountProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, GatewayError>> {
|
||||
Box::pin(async {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
"included usage exhausted",
|
||||
Some("usage_exhausted".to_owned()),
|
||||
Some("invalid_request_error".to_owned()),
|
||||
)
|
||||
.expect("safe provider error");
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::RateLimited,
|
||||
"upstream capacity is temporarily unavailable",
|
||||
)
|
||||
.with_client_visible_upstream_error(detail))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn store_unavailable() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
|
||||
@@ -174,7 +174,7 @@ pub struct AccountSummaryView {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub attention: u64,
|
||||
pub unavailable: u64,
|
||||
}
|
||||
|
||||
/// 一条安全账号视图。
|
||||
@@ -543,10 +543,14 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
|
||||
}),
|
||||
DomainConnectionTestEvent::Failed {
|
||||
message,
|
||||
provider_error_code,
|
||||
provider_error_type,
|
||||
account_status,
|
||||
} => serde_json::json!({
|
||||
"type": "error",
|
||||
"error": message,
|
||||
"providerErrorCode": provider_error_code,
|
||||
"providerErrorType": provider_error_type,
|
||||
"accountStatus": account_status_name(account_status)
|
||||
}),
|
||||
};
|
||||
@@ -1401,7 +1405,7 @@ fn account_page_data(
|
||||
total: result.summary.total,
|
||||
active: result.summary.active,
|
||||
quota_exhausted: result.summary.quota_exhausted,
|
||||
attention: result.summary.attention,
|
||||
unavailable: result.summary.unavailable,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1798,7 +1802,6 @@ fn account_status_name(status: DomainAccountStatus) -> &'static str {
|
||||
DomainAccountStatus::QuotaExhausted => "quota_exhausted",
|
||||
DomainAccountStatus::Disabled => "disabled",
|
||||
DomainAccountStatus::Banned => "banned",
|
||||
DomainAccountStatus::Attention => "attention",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -801,8 +801,8 @@ pub struct DashboardCardsView {
|
||||
pub struct DashboardCredentialsCardView {
|
||||
pub total: String,
|
||||
pub total_value: u64,
|
||||
pub enabled: String,
|
||||
pub enabled_value: u64,
|
||||
pub available: String,
|
||||
pub available_value: u64,
|
||||
pub unavailable: String,
|
||||
pub unavailable_value: u64,
|
||||
}
|
||||
@@ -1235,7 +1235,6 @@ pub struct OpsErrorView {
|
||||
pub account_id: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub client_status_code: Option<i64>,
|
||||
pub upstream_status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
@@ -1954,7 +1953,6 @@ fn usage_page_view(
|
||||
}
|
||||
|
||||
fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
let status = error.status_code.map(i64::from);
|
||||
OpsErrorView {
|
||||
id: error.event_id,
|
||||
request_id: error.request_id,
|
||||
@@ -1964,9 +1962,8 @@ fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
account_id: error.provider_account_ref,
|
||||
route: error.operation,
|
||||
model: error.upstream_model_id,
|
||||
status_code: status,
|
||||
client_status_code: None,
|
||||
upstream_status_code: None,
|
||||
client_status_code: error.client_status_code.map(i64::from),
|
||||
upstream_status_code: error.upstream_status_code.map(i64::from),
|
||||
transport: error.upstream_transport,
|
||||
attempt_index: error.attempt_index,
|
||||
failure_class: error.failure_kind,
|
||||
@@ -2290,8 +2287,8 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
|
||||
credentials: DashboardCredentialsCardView {
|
||||
total: format_compact_number(provider_accounts.total),
|
||||
total_value: provider_accounts.total,
|
||||
enabled: format_compact_number(provider_accounts.enabled),
|
||||
enabled_value: provider_accounts.enabled,
|
||||
available: format_compact_number(provider_accounts.active),
|
||||
available_value: provider_accounts.active,
|
||||
unavailable: format_compact_number(provider_accounts.unavailable),
|
||||
unavailable_value: provider_accounts.unavailable,
|
||||
},
|
||||
|
||||
@@ -201,11 +201,13 @@ struct RestartAcceptedView {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemUpdateEventView {
|
||||
id: String,
|
||||
operation_id: Option<String>,
|
||||
level: &'static str,
|
||||
step: Option<String>,
|
||||
message: String,
|
||||
terminal: bool,
|
||||
progress_percent: Option<u8>,
|
||||
at: String,
|
||||
}
|
||||
|
||||
@@ -403,11 +405,13 @@ where
|
||||
impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
fn from(event: SystemUpdateEvent) -> Self {
|
||||
Self {
|
||||
id: event.id,
|
||||
operation_id: event.operation_id,
|
||||
level: update_event_level_name(event.level),
|
||||
step: event.step,
|
||||
message: event.message,
|
||||
terminal: event.terminal,
|
||||
progress_percent: event.progress_percent,
|
||||
at: event.occurred_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -416,11 +420,13 @@ impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
impl SystemUpdateEventView {
|
||||
fn into_json(self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"operationId": self.operation_id,
|
||||
"level": self.level,
|
||||
"step": self.step,
|
||||
"message": self.message,
|
||||
"terminal": self.terminal,
|
||||
"progressPercent": self.progress_percent,
|
||||
"at": self.at,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ApiState;
|
||||
@@ -17,6 +17,7 @@ use super::{
|
||||
};
|
||||
|
||||
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
|
||||
const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result.";
|
||||
|
||||
/// `GET /v1/models`。
|
||||
pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||
@@ -30,6 +31,24 @@ pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) ->
|
||||
.into_iter()
|
||||
.map(|model| openai_model_json(&model))
|
||||
.collect::<Vec<_>>();
|
||||
let profiles = service.public_model_profiles(&client);
|
||||
|
||||
if !profiles.is_empty() {
|
||||
let models = profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"models": models,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -134,3 +153,76 @@ fn catalog_model_json(id: &str) -> Value {
|
||||
"source": "gateway",
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
let id = profile.model().as_str();
|
||||
let presentation = profile.presentation();
|
||||
let reasoning_levels = presentation
|
||||
.supported_reasoning_efforts()
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
json!({
|
||||
"effort": effort,
|
||||
"description": reasoning_effort_description(effort),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reasoning_supported = presentation
|
||||
.supported_reasoning_efforts()
|
||||
.iter()
|
||||
.any(|effort| effort != "none");
|
||||
let context_window = presentation.context_window_tokens();
|
||||
let input_modalities = if presentation.image_input() {
|
||||
vec!["text", "image"]
|
||||
} else {
|
||||
vec!["text"]
|
||||
};
|
||||
|
||||
json!({
|
||||
"slug": id,
|
||||
"display_name": presentation.display_name().unwrap_or(id),
|
||||
"description": presentation.description(),
|
||||
"default_reasoning_level": presentation.default_reasoning_effort(),
|
||||
"supported_reasoning_levels": reasoning_levels,
|
||||
"shell_type": "shell_command",
|
||||
"visibility": if presentation.hidden() { "hide" } else { "list" },
|
||||
"supported_in_api": true,
|
||||
"priority": index.saturating_add(1),
|
||||
"additional_speed_tiers": [],
|
||||
"service_tiers": [],
|
||||
"availability_nux": Value::Null,
|
||||
"upgrade": Value::Null,
|
||||
"base_instructions": CODEX_BASE_INSTRUCTIONS,
|
||||
"model_messages": Value::Null,
|
||||
"include_skills_usage_instructions": false,
|
||||
"supports_reasoning_summary_parameter": reasoning_supported,
|
||||
"default_reasoning_summary": "auto",
|
||||
"support_verbosity": presentation.verbosity(),
|
||||
"default_verbosity": Value::Null,
|
||||
"apply_patch_tool_type": presentation.agent_tools().then_some("freeform"),
|
||||
"web_search_tool_type": "text",
|
||||
"truncation_policy": { "mode": "tokens", "limit": 10_000 },
|
||||
"supports_parallel_tool_calls": presentation.parallel_tool_calls(),
|
||||
"supports_image_detail_original": presentation.image_detail_original(),
|
||||
"context_window": context_window,
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": input_modalities,
|
||||
"supports_search_tool": presentation.search_tool(),
|
||||
"use_responses_lite": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn reasoning_effort_description(effort: &str) -> &'static str {
|
||||
match effort {
|
||||
"none" => "No reasoning",
|
||||
"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",
|
||||
"xhigh" => "Extra high reasoning depth for complex problems",
|
||||
"max" => "Maximum reasoning depth for the hardest problems",
|
||||
_ => "Provider-supported reasoning level",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use gateway_core::engine::execution::{
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::auth::ClientApiKeyAuthError;
|
||||
@@ -51,6 +51,13 @@ impl OpenAiService {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn public_model_profiles(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
) -> Vec<PublicModelProfile> {
|
||||
self.execution.public_model_profiles(client)
|
||||
}
|
||||
|
||||
pub(crate) fn contains_public_model(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
|
||||
@@ -276,6 +276,8 @@ mod actions {
|
||||
},
|
||||
DomainConnectionTestEvent::Failed {
|
||||
message: "upstream unavailable".to_owned(),
|
||||
provider_error_code: Some("usage_exhausted".to_owned()),
|
||||
provider_error_type: Some("invalid_request_error".to_owned()),
|
||||
account_status: AccountStatus::Active,
|
||||
},
|
||||
]
|
||||
@@ -302,7 +304,13 @@ mod actions {
|
||||
}),
|
||||
json!({ "type": "content", "text": "OK" }),
|
||||
json!({ "type": "test_complete", "success": true, "accountStatus": "active" }),
|
||||
json!({ "type": "error", "error": "upstream unavailable", "accountStatus": "active" }),
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": "upstream unavailable",
|
||||
"providerErrorCode": "usage_exhausted",
|
||||
"providerErrorType": "invalid_request_error",
|
||||
"accountStatus": "active"
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ pub(super) struct AdminTestFixture {
|
||||
|
||||
impl AdminTestFixture {
|
||||
pub async fn new() -> Self {
|
||||
Self::with_system(Arc::new(UnusedSystem)).await
|
||||
}
|
||||
|
||||
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
|
||||
let api_key = Arc::new(Mutex::new(None));
|
||||
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
|
||||
let settings = Arc::new(MemorySettingsStore::new(api_key));
|
||||
@@ -113,7 +117,7 @@ impl AdminTestFixture {
|
||||
providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
Arc::new(NoopProbe),
|
||||
Arc::new(UnusedSystem),
|
||||
system,
|
||||
)
|
||||
.await
|
||||
.expect("initialize test admin services");
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
use gateway_api::admin::system::{UpdateDetailQuery, UpdateRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures::stream;
|
||||
use gateway_admin::{
|
||||
model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
|
||||
};
|
||||
use gateway_api::admin::system::{self, UpdateDetailQuery, UpdateRequest};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState, unavailable_system};
|
||||
|
||||
#[test]
|
||||
fn update_detail_query_should_default_to_no_refresh_and_reject_unknown_fields() {
|
||||
@@ -16,3 +36,84 @@ fn update_request_should_preserve_target_version_for_domain_validation() {
|
||||
serde_json::from_str::<UpdateRequest>(r#"{"targetVersion":"v0.2.0","extra":1}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_event_stream_should_preserve_event_id_and_download_progress_percent() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/system/update/events")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_system_update_events")
|
||||
.body(Body::empty())
|
||||
.expect("update event request"),
|
||||
)
|
||||
.await
|
||||
.expect("update event response");
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("update event body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("UTF-8 event stream");
|
||||
|
||||
assert!(
|
||||
body.contains(r#""id":"update-progress-1""#),
|
||||
"unexpected update event stream: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(r#""progressPercent":40"#),
|
||||
"unexpected update event stream: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
fn app(state: AdminTestState) -> Router {
|
||||
system::router::<AdminTestState>().with_state(state)
|
||||
}
|
||||
|
||||
struct ProgressSystem;
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for ProgressSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(stream::once(async {
|
||||
SystemUpdateEvent {
|
||||
id: "update-progress-1".to_owned(),
|
||||
operation_id: Some("update-1".to_owned()),
|
||||
level: SystemUpdateEventLevel::Info,
|
||||
step: Some("download".to_owned()),
|
||||
message: "已下载 4.0 MiB / 10.0 MiB (40%)".to_owned(),
|
||||
terminal: true,
|
||||
progress_percent: Some(40),
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,28 @@ use gateway_core::engine::execution::{
|
||||
StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::routing::{ModelPresentation, PublicModelId, PublicModelProfile};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
struct ModelsExecution {
|
||||
client: AuthenticatedClient,
|
||||
profiles: bool,
|
||||
}
|
||||
|
||||
impl ModelsExecution {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
profiles: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_profiles() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
profiles: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +55,28 @@ impl ExecutionService for ModelsExecution {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
|
||||
if !self.profiles {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![PublicModelProfile::new(
|
||||
PublicModelId::new("grok-4.5").expect("model"),
|
||||
ModelPresentation::new(
|
||||
Some("Grok 4.5".to_owned()),
|
||||
Some("xAI Grok 4.5 frontier model.".to_owned()),
|
||||
)
|
||||
.with_reasoning(
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
.with_agent_tools(true, true),
|
||||
)]
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
matches!(model.as_str(), "model-a" | "model-b")
|
||||
}
|
||||
@@ -91,6 +122,74 @@ async fn models_should_encode_the_service_visible_catalog() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
let response = api_router(ModelsExecution::with_profiles())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
|
||||
.await
|
||||
.expect("list Codex models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read Codex models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
|
||||
let model = &value["models"][0];
|
||||
|
||||
assert_eq!(model["slug"], "grok-4.5");
|
||||
assert_eq!(model["default_reasoning_level"], "medium");
|
||||
assert_eq!(model["context_window"], 500_000);
|
||||
assert_eq!(model["apply_patch_tool_type"], "freeform");
|
||||
assert_eq!(model["service_tiers"], serde_json::json!([]));
|
||||
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]
|
||||
async fn model_detail_should_keep_the_official_path_id_contract() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 唯一的账号重试、发送与下游 commit barrier owner。
|
||||
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::collections::BTreeSet;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
@@ -67,12 +67,6 @@ where
|
||||
let continuation_attempt =
|
||||
initial_continuation_attempt(&operation, &plan, continuation.as_ref());
|
||||
let image_generation_requested = operation.image_generation_requested();
|
||||
let commit_policy = if continuation_attempt == ContinuationAttempt::None {
|
||||
StreamCommitPolicy::FirstCanonicalEvent
|
||||
} else {
|
||||
StreamCommitPolicy::UntilOutputOrTerminal
|
||||
};
|
||||
|
||||
let mut session = ResponseExecutionSession {
|
||||
engine: Arc::clone(&self.engine),
|
||||
request_id,
|
||||
@@ -86,7 +80,6 @@ where
|
||||
required_account,
|
||||
continuation,
|
||||
continuation_attempt,
|
||||
commit_policy,
|
||||
account_state_owner,
|
||||
cancellation,
|
||||
attempts: 0,
|
||||
@@ -106,7 +99,6 @@ where
|
||||
upstream_response_id: None,
|
||||
last_account_exhaustion: None,
|
||||
provider_attempt_outcomes: Vec::new(),
|
||||
before_commit: VecDeque::new(),
|
||||
};
|
||||
|
||||
if session.cancellation.is_cancelled() {
|
||||
@@ -141,12 +133,6 @@ struct FailureFinalization {
|
||||
provider_response_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum StreamCommitPolicy {
|
||||
FirstCanonicalEvent,
|
||||
UntilOutputOrTerminal,
|
||||
}
|
||||
|
||||
/// API 可逐事件消费的 Core 执行会话。
|
||||
///
|
||||
/// API 只能提交下游 delivery 边界;账号重试、断流终结与
|
||||
@@ -164,7 +150,6 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
required_account: Option<crate::engine::credential::ProviderAccountId>,
|
||||
continuation: Option<ContinuationBinding>,
|
||||
continuation_attempt: ContinuationAttempt,
|
||||
commit_policy: StreamCommitPolicy,
|
||||
account_state_owner: Option<ProviderAccountStateOwner>,
|
||||
cancellation: CancellationToken,
|
||||
attempts: u32,
|
||||
@@ -184,7 +169,6 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
upstream_response_id: Option<String>,
|
||||
last_account_exhaustion: Option<ProviderError>,
|
||||
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
|
||||
before_commit: VecDeque<ProviderEvent>,
|
||||
}
|
||||
|
||||
impl<S: ?Sized> ResponseExecutionSession<S>
|
||||
@@ -203,15 +187,6 @@ where
|
||||
if self.finalized {
|
||||
return Ok(None);
|
||||
}
|
||||
if self.downstream_committed_at.is_some()
|
||||
&& let Some(event) = self.before_commit.pop_front()
|
||||
{
|
||||
return Ok(Some(CoordinatedEvent::single(
|
||||
event,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)));
|
||||
}
|
||||
|
||||
loop {
|
||||
match self.pull().await? {
|
||||
PullOutcome::Event(event) => {
|
||||
@@ -221,25 +196,13 @@ where
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)));
|
||||
}
|
||||
let commit_significant = match self.commit_policy {
|
||||
StreamCommitPolicy::FirstCanonicalEvent => event.has_canonical_facts(),
|
||||
StreamCommitPolicy::UntilOutputOrTerminal => event.is_commit_significant(),
|
||||
};
|
||||
self.before_commit.push_back(event);
|
||||
if !commit_significant {
|
||||
continue;
|
||||
}
|
||||
self.delivery_pending = true;
|
||||
let events = self.before_commit.drain(..).collect();
|
||||
return CoordinatedEvent::try_batch(
|
||||
events,
|
||||
return Ok(Some(CoordinatedEvent::single(
|
||||
event,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)
|
||||
.map(Some);
|
||||
}
|
||||
PullOutcome::AttemptDiscarded => {
|
||||
self.before_commit.clear();
|
||||
)));
|
||||
}
|
||||
PullOutcome::AttemptDiscarded => {}
|
||||
PullOutcome::End => {
|
||||
if self.downstream_committed_at.is_some() {
|
||||
self.finish_success().await?;
|
||||
@@ -953,7 +916,6 @@ where
|
||||
self.timings.first_text_ms = None;
|
||||
self.timings.first_token_ms = None;
|
||||
self.upstream_complete = false;
|
||||
self.before_commit.clear();
|
||||
}
|
||||
|
||||
async fn finish_success(&mut self) -> Result<(), EngineError> {
|
||||
|
||||
@@ -29,7 +29,9 @@ use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
|
||||
use crate::operation::{Operation, ProviderSessionState};
|
||||
use crate::policy::{ClientApiKeyId, ClientPolicy};
|
||||
use crate::routing::snapshot::RuntimeSnapshotHandle;
|
||||
use crate::routing::{ProviderKind, PublicModelId, RoutingContext, RuntimeSnapshot};
|
||||
use crate::routing::{
|
||||
ProviderKind, PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot,
|
||||
};
|
||||
|
||||
const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60);
|
||||
const CONTINUATION_AFFINITY_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
@@ -139,6 +141,9 @@ pub trait ExecutionService: Send + Sync {
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientAuthenticationError>;
|
||||
fn public_models(&self, client: &AuthenticatedClient) -> Vec<PublicModelId>;
|
||||
fn public_model_profiles(&self, _client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
|
||||
Vec::new()
|
||||
}
|
||||
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool;
|
||||
fn start(
|
||||
&self,
|
||||
@@ -568,6 +573,12 @@ impl ExecutionService for DefaultExecutionService {
|
||||
.public_models_for_provider(client.policy.provider_kind())
|
||||
}
|
||||
|
||||
fn public_model_profiles(&self, client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
|
||||
client
|
||||
.snapshot
|
||||
.public_model_profiles_for_provider(client.policy.provider_kind())
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
client
|
||||
.snapshot
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::error::{
|
||||
};
|
||||
use crate::event::{EventSequenceValidator, ProviderEvent};
|
||||
use crate::operation::Operation;
|
||||
use crate::routing::{ModelCapabilities, ProviderCandidate, ProviderKind, UpstreamModelId};
|
||||
use crate::routing::{
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
|
||||
};
|
||||
|
||||
/// Box 只出现在 Provider Registry 的统一 event envelope 边界。
|
||||
pub type EventStream =
|
||||
@@ -433,6 +435,7 @@ pub struct ProviderRequestObservation {
|
||||
pub struct ProviderModelCapabilities {
|
||||
upstream_model: UpstreamModelId,
|
||||
capabilities: ModelCapabilities,
|
||||
presentation: Option<ModelPresentation>,
|
||||
}
|
||||
|
||||
/// Provider 实时目录成功发布后的进程内单调代次。
|
||||
@@ -459,9 +462,16 @@ impl ProviderModelCapabilities {
|
||||
Self {
|
||||
upstream_model,
|
||||
capabilities,
|
||||
presentation: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
|
||||
self.presentation = Some(presentation);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn upstream_model(&self) -> &UpstreamModelId {
|
||||
&self.upstream_model
|
||||
@@ -471,6 +481,11 @@ impl ProviderModelCapabilities {
|
||||
pub const fn capabilities(&self) -> &ModelCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn presentation(&self) -> Option<&ModelPresentation> {
|
||||
self.presentation.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRequest {
|
||||
|
||||
@@ -756,18 +756,6 @@ impl ProviderEvent {
|
||||
pub fn has_client_event(&self) -> bool {
|
||||
!self.canonical.is_empty() || self.wire.is_some()
|
||||
}
|
||||
|
||||
/// 返回是否包含足以冻结下游 commit barrier 的 canonical fact。
|
||||
///
|
||||
/// 单独的 wire event 不能冻结边界:协议层在看到 `Started`
|
||||
/// 携带的网关响应身份前无法安全编码它。Core 会保留这些
|
||||
/// 事件,直到首个 canonical 可见增量或终态再一并交付。
|
||||
#[must_use]
|
||||
pub fn is_commit_significant(&self) -> bool {
|
||||
self.canonical
|
||||
.iter()
|
||||
.any(GatewayEvent::is_commit_significant)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GatewayEvent> for ProviderEvent {
|
||||
@@ -776,23 +764,6 @@ impl From<GatewayEvent> for ProviderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
impl GatewayEvent {
|
||||
/// 返回该 fact 是否足以冻结下游 commit barrier。
|
||||
///
|
||||
/// 生命周期、结构与结算 facts 可以在换号前安全丢弃;首个可见增量或
|
||||
/// Provider 正常终态才会使客户端输出不可撤回。
|
||||
#[must_use]
|
||||
pub const fn is_commit_significant(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::TextDelta(_)
|
||||
| Self::ReasoningDelta(_)
|
||||
| Self::ToolCallDelta(_)
|
||||
| Self::Completed(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical event 顺序错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum EventSequenceError {
|
||||
|
||||
@@ -476,7 +476,7 @@ pub trait ProviderCredentialStatePort: Send + Sync {
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
|
||||
}
|
||||
|
||||
/// 临时 cooldown 只保存调度截止时间;原因事实仍由 PostgreSQL 账号状态持有。
|
||||
/// 临时 cooldown 只保存可丢失的调度截止时间,不进入账号持久状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderCooldown {
|
||||
account_id: ProviderAccountId,
|
||||
|
||||
@@ -136,6 +136,177 @@ pub struct ModelCapabilities {
|
||||
upstream_validates_features: bool,
|
||||
}
|
||||
|
||||
/// Provider 为客户端模型目录提供的展示与交互能力。
|
||||
///
|
||||
/// 该值不参与路由,也不把任一 Provider 的 wire 类型带入 Core。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ModelPresentation {
|
||||
display_name: Option<String>,
|
||||
description: Option<String>,
|
||||
default_reasoning_effort: Option<String>,
|
||||
supported_reasoning_efforts: Vec<String>,
|
||||
context_window_tokens: Option<u64>,
|
||||
image_input: bool,
|
||||
agent_tools: bool,
|
||||
parallel_tool_calls: bool,
|
||||
search_tool: bool,
|
||||
image_detail_original: bool,
|
||||
verbosity: bool,
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
impl ModelPresentation {
|
||||
#[must_use]
|
||||
pub fn new(display_name: Option<String>, description: Option<String>) -> Self {
|
||||
Self {
|
||||
display_name,
|
||||
description,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_reasoning(
|
||||
mut self,
|
||||
default_effort: Option<String>,
|
||||
supported_efforts: Vec<String>,
|
||||
) -> Self {
|
||||
self.default_reasoning_effort = default_effort;
|
||||
self.supported_reasoning_efforts = supported_efforts;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_context_window_tokens(mut self, context_window_tokens: Option<u64>) -> Self {
|
||||
self.context_window_tokens = context_window_tokens;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_image_input(mut self, image_input: bool) -> Self {
|
||||
self.image_input = image_input;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_agent_tools(mut self, agent_tools: bool, parallel_tool_calls: bool) -> Self {
|
||||
self.agent_tools = agent_tools;
|
||||
self.parallel_tool_calls = parallel_tool_calls;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_search_tool(mut self, search_tool: bool) -> Self {
|
||||
self.search_tool = search_tool;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_image_detail_original(mut self, image_detail_original: bool) -> Self {
|
||||
self.image_detail_original = image_detail_original;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_verbosity(mut self, verbosity: bool) -> Self {
|
||||
self.verbosity = verbosity;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_hidden(mut self, hidden: bool) -> Self {
|
||||
self.hidden = hidden;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> Option<&str> {
|
||||
self.display_name.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_reasoning_effort(&self) -> Option<&str> {
|
||||
self.default_reasoning_effort.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supported_reasoning_efforts(&self) -> &[String] {
|
||||
&self.supported_reasoning_efforts
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn context_window_tokens(&self) -> Option<u64> {
|
||||
self.context_window_tokens
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn image_input(&self) -> bool {
|
||||
self.image_input
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn agent_tools(&self) -> bool {
|
||||
self.agent_tools
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn parallel_tool_calls(&self) -> bool {
|
||||
self.parallel_tool_calls
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn search_tool(&self) -> bool {
|
||||
self.search_tool
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn image_detail_original(&self) -> bool {
|
||||
self.image_detail_original
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn verbosity(&self) -> bool {
|
||||
self.verbosity
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn hidden(&self) -> bool {
|
||||
self.hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个公开模型及其 Provider 编译后的客户端画像。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PublicModelProfile {
|
||||
model: PublicModelId,
|
||||
presentation: ModelPresentation,
|
||||
}
|
||||
|
||||
impl PublicModelProfile {
|
||||
#[must_use]
|
||||
pub const fn new(model: PublicModelId, presentation: ModelPresentation) -> Self {
|
||||
Self {
|
||||
model,
|
||||
presentation,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model(&self) -> &PublicModelId {
|
||||
&self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn presentation(&self) -> &ModelPresentation {
|
||||
&self.presentation
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelCapabilities {
|
||||
#[must_use]
|
||||
pub fn new(operations: BTreeSet<OperationKind>, max_output_tokens: Option<u64>) -> Self {
|
||||
@@ -205,6 +376,7 @@ pub struct ProviderModel {
|
||||
provider: ProviderKind,
|
||||
upstream_model: UpstreamModelId,
|
||||
capabilities: ModelCapabilities,
|
||||
presentation: Option<ModelPresentation>,
|
||||
}
|
||||
|
||||
impl ProviderModel {
|
||||
@@ -218,9 +390,16 @@ impl ProviderModel {
|
||||
provider,
|
||||
upstream_model,
|
||||
capabilities,
|
||||
presentation: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
|
||||
self.presentation = Some(presentation);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider(&self) -> &ProviderKind {
|
||||
&self.provider
|
||||
@@ -230,6 +409,11 @@ impl ProviderModel {
|
||||
pub const fn upstream_model(&self) -> &UpstreamModelId {
|
||||
&self.upstream_model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn presentation(&self) -> Option<&ModelPresentation> {
|
||||
self.presentation.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次请求选择 Provider 时使用的动态过滤事实。
|
||||
|
||||
@@ -231,11 +231,15 @@ async fn compile_runtime_snapshot(
|
||||
continue;
|
||||
};
|
||||
provider_models.extend(models.into_iter().map(|model| {
|
||||
ProviderModel::new(
|
||||
let compiled = ProviderModel::new(
|
||||
provider.clone(),
|
||||
model.upstream_model().clone(),
|
||||
model.capabilities().clone(),
|
||||
)
|
||||
);
|
||||
match model.presentation().cloned() {
|
||||
Some(presentation) => compiled.with_presentation(presentation),
|
||||
None => compiled,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -287,6 +291,8 @@ pub struct RuntimeSnapshot {
|
||||
account_selection_policy: AccountSelectionPolicy,
|
||||
providers: Arc<BTreeSet<ProviderKind>>,
|
||||
provider_models: Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>>,
|
||||
provider_model_presentations:
|
||||
Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>>,
|
||||
model_mappings: Arc<BTreeMap<String, String>>,
|
||||
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
|
||||
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
|
||||
@@ -313,17 +319,24 @@ impl RuntimeSnapshot {
|
||||
|
||||
let mut model_map =
|
||||
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>::new();
|
||||
let mut presentation_map =
|
||||
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>::new();
|
||||
for model in provider_models {
|
||||
if !provider_set.contains(model.provider()) {
|
||||
let ProviderModel {
|
||||
provider,
|
||||
upstream_model,
|
||||
capabilities,
|
||||
presentation,
|
||||
} = model;
|
||||
if !provider_set.contains(&provider) {
|
||||
return Err(RoutingError::NotFound {
|
||||
entity: "provider",
|
||||
id: model.provider().to_string(),
|
||||
id: provider.to_string(),
|
||||
});
|
||||
}
|
||||
let models = model_map.entry(model.provider).or_default();
|
||||
let upstream_model = model.upstream_model;
|
||||
let models = model_map.entry(provider.clone()).or_default();
|
||||
if models
|
||||
.insert(upstream_model.clone(), model.capabilities)
|
||||
.insert(upstream_model.clone(), capabilities)
|
||||
.is_some()
|
||||
{
|
||||
return Err(RoutingError::DuplicateEntity {
|
||||
@@ -331,6 +344,12 @@ impl RuntimeSnapshot {
|
||||
id: upstream_model.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(presentation) = presentation {
|
||||
presentation_map
|
||||
.entry(provider)
|
||||
.or_default()
|
||||
.insert(upstream_model, presentation);
|
||||
}
|
||||
}
|
||||
|
||||
let mut client_policy_map = BTreeMap::new();
|
||||
@@ -350,6 +369,7 @@ impl RuntimeSnapshot {
|
||||
account_selection_policy,
|
||||
providers: Arc::new(provider_set),
|
||||
provider_models: Arc::new(model_map),
|
||||
provider_model_presentations: Arc::new(presentation_map),
|
||||
model_mappings: Arc::new(BTreeMap::new()),
|
||||
provider_catalog_generations: Arc::new(BTreeMap::new()),
|
||||
client_policies: Arc::new(client_policy_map),
|
||||
@@ -402,6 +422,38 @@ impl RuntimeSnapshot {
|
||||
models.into_iter().collect()
|
||||
}
|
||||
|
||||
/// 返回 Provider 已明确声明画像的公开模型;没有画像时不猜测 Provider 语义。
|
||||
#[must_use]
|
||||
pub fn public_model_profiles_for_provider(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
) -> Vec<super::PublicModelProfile> {
|
||||
let Some(presentations) = self.provider_model_presentations.get(provider) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut profiles = BTreeMap::new();
|
||||
for (model, presentation) in presentations {
|
||||
if let Ok(public_model) = PublicModelId::new(model.as_str().to_owned()) {
|
||||
profiles.insert(public_model, presentation.clone());
|
||||
}
|
||||
}
|
||||
for alias in self.model_mappings.keys() {
|
||||
let target = self.mapped_model(alias);
|
||||
let Some(presentation) = presentations.iter().find_map(|(model, presentation)| {
|
||||
(model.as_str() == target).then_some(presentation)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(public_model) = PublicModelId::new(alias.clone()) {
|
||||
profiles.insert(public_model, presentation.clone());
|
||||
}
|
||||
}
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|(model, presentation)| super::PublicModelProfile::new(model, presentation))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn public_models(&self) -> Vec<PublicModelId> {
|
||||
self.providers
|
||||
|
||||
@@ -1005,6 +1005,51 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_native_continuation_delivers_started_event_before_later_output() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, _) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_one",
|
||||
items: complete_stream(None),
|
||||
}]);
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new("previous-response").expect("previous response"),
|
||||
SafeUpstreamValue::new("upstream-response").expect("upstream response"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
ProviderAccountId::new("acct_one").expect("account"),
|
||||
);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let first = block_on(session.next_event())
|
||||
.expect("first event")
|
||||
.expect("started event");
|
||||
assert_eq!(
|
||||
first.commit_requirement(),
|
||||
CommitRequirement::CommitBeforeDelivery
|
||||
);
|
||||
let events = first.into_provider_events();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[event]
|
||||
if matches!(event.canonical_facts(), [GatewayEvent::Started(_)])
|
||||
));
|
||||
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit started event");
|
||||
while block_on(session.next_event())
|
||||
.expect("remaining event")
|
||||
.is_some()
|
||||
{}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
|
||||
@@ -24,8 +24,8 @@ use gateway_core::routing::snapshot::{
|
||||
SnapshotSubscriptionError, SnapshotSubscriptionPort, runtime_revision_needs_refresh,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ProviderKind, PublicModelId, RuntimeSnapshot,
|
||||
UpstreamModelId,
|
||||
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderKind, PublicModelId,
|
||||
RuntimeSnapshot, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::task::WorkerKind;
|
||||
|
||||
@@ -135,13 +135,19 @@ impl Provider for PublishingCatalogProvider {
|
||||
if self.queries.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
self.generation.store(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(vec![ProviderModelCapabilities::new(
|
||||
UpstreamModelId::new("upstream-model").expect("model"),
|
||||
ModelCapabilities::new(
|
||||
std::collections::BTreeSet::from([OperationKind::Generate]),
|
||||
Ok(vec![
|
||||
ProviderModelCapabilities::new(
|
||||
UpstreamModelId::new("upstream-model").expect("model"),
|
||||
ModelCapabilities::new(
|
||||
std::collections::BTreeSet::from([OperationKind::Generate]),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.with_presentation(ModelPresentation::new(
|
||||
Some("Upstream Model".to_owned()),
|
||||
None,
|
||||
),
|
||||
)])
|
||||
)),
|
||||
])
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -215,6 +221,15 @@ fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
|
||||
.map(|generation| generation.get()),
|
||||
Some(1),
|
||||
);
|
||||
let profiles =
|
||||
snapshot.public_model_profiles_for_provider(&ProviderKind::new("alpha").expect("provider"));
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|profile| profile.model().as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["public-model", "upstream-model"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) async fn download_file(
|
||||
.map_err(|error| super::internal(format!("failed to create download file: {error}")))?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded = 0_u64;
|
||||
let mut last_percent = 0_u8;
|
||||
let mut next_progress = 10_u8;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| upstream(format!("download stream failed: {error}")))?;
|
||||
downloaded = downloaded.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
|
||||
@@ -59,9 +59,17 @@ pub(crate) async fn download_file(
|
||||
file.write_all(&chunk)
|
||||
.map_err(|error| super::internal(format!("failed to write download: {error}")))?;
|
||||
let percent = download_percent(downloaded, expected_size);
|
||||
if percent > last_percent {
|
||||
last_percent = percent;
|
||||
events.emit_progress(operation_id, "downloading release archive", percent);
|
||||
if percent >= next_progress || downloaded == expected_size {
|
||||
events.emit_progress(
|
||||
operation_id,
|
||||
&format!(
|
||||
"已下载 {} / {} ({percent}%)",
|
||||
format_bytes(downloaded),
|
||||
format_bytes(expected_size)
|
||||
),
|
||||
percent,
|
||||
);
|
||||
next_progress = percent.saturating_add(10);
|
||||
}
|
||||
}
|
||||
file.sync_all()
|
||||
@@ -155,3 +163,15 @@ fn download_percent(downloaded: u64, total: u64) -> u8 {
|
||||
)
|
||||
.unwrap_or(100)
|
||||
}
|
||||
|
||||
pub(crate) fn format_bytes(bytes: u64) -> String {
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = KIB * 1024;
|
||||
if bytes >= MIB {
|
||||
return format!("{:.1} MiB", bytes as f64 / MIB as f64);
|
||||
}
|
||||
if bytes >= KIB {
|
||||
return format!("{:.1} KiB", bytes as f64 / KIB as f64);
|
||||
}
|
||||
format!("{bytes} B")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,8 @@ const MAX_PAGE_SIZE: u16 = 100;
|
||||
const MAX_FILTER_BYTES: usize = 256;
|
||||
const MAX_SEARCH_BYTES: usize = 512;
|
||||
const MAX_ACCOUNT_IDS: usize = 200;
|
||||
const DASHBOARD_ACCOUNT_LIMIT: u16 = 50;
|
||||
/// 概览卡只展示最近使用的四个账号;完整账号用量由账号管理页单独查询。
|
||||
const DASHBOARD_ACCOUNT_LIMIT: u16 = 4;
|
||||
const DIAGNOSTIC_LIMIT: i64 = 100;
|
||||
const ACCOUNT_USAGE_TIMELINE_HOURS: i64 = 24;
|
||||
|
||||
@@ -660,7 +661,8 @@ pub struct OpsErrorRecord {
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub failure_kind: String,
|
||||
pub status_code: Option<u16>,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub client_response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
@@ -1626,7 +1628,8 @@ fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
|
||||
upstream_model_id: error.upstream_model_id,
|
||||
upstream_transport: error.upstream_transport,
|
||||
failure_kind: error.failure_kind,
|
||||
status_code: error.status_code,
|
||||
client_status_code: error.client_status_code,
|
||||
upstream_status_code: error.upstream_status_code,
|
||||
provider_error_code: error.provider_error_code,
|
||||
client_response_id: error.client_response_id,
|
||||
upstream_request_id: error.upstream_request_id,
|
||||
@@ -1977,8 +1980,12 @@ async fn provider_account_metrics(
|
||||
let row = sqlx::query(
|
||||
"select count(*)::bigint as total,
|
||||
count(*) filter (where enabled)::bigint as enabled,
|
||||
count(*) filter (where not enabled or availability <> 'ready')::bigint
|
||||
as unavailable,
|
||||
count(*) filter (
|
||||
where not (
|
||||
enabled and availability = 'ready'
|
||||
and (access_token_expires_at is null or access_token_expires_at > $1)
|
||||
)
|
||||
)::bigint as unavailable,
|
||||
count(*) filter (
|
||||
where enabled and availability = 'ready'
|
||||
and (access_token_expires_at is null or access_token_expires_at > $1)
|
||||
@@ -2921,7 +2928,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
|
||||
mr.provider_kind,
|
||||
mr.provider_account_ref, mr.upstream_model_id, mr.upstream_transport,
|
||||
coalesce(mr.error_kind, 'failed') as failure_kind,
|
||||
coalesce(mr.upstream_status_code, mr.client_status_code) as status_code,
|
||||
mr.client_status_code, mr.upstream_status_code,
|
||||
mr.provider_error_code, mr.client_response_id, mr.upstream_request_id,
|
||||
mr.latency_ms, coalesce(mr.error_message, mr.error_kind, 'request failed') as message,
|
||||
1::integer as occurrence_count,
|
||||
@@ -2932,7 +2939,8 @@ const OPS_ERRORS_CTE: &str = "with errors as (
|
||||
select 'ops_event'::text, oe.id, oe.model_request_id, oe.attempt_index,
|
||||
mr.client_api_key_ref, oe.component, oe.operation,
|
||||
oe.provider_kind, oe.provider_account_ref,
|
||||
oe.upstream_model_id, null::text, oe.failure_kind, oe.status_code,
|
||||
oe.upstream_model_id, null::text, oe.failure_kind,
|
||||
null::integer as client_status_code, oe.status_code as upstream_status_code,
|
||||
oe.provider_error_code, mr.client_response_id, oe.upstream_request_id,
|
||||
oe.latency_ms, oe.message, oe.occurrence_count, oe.created_at,
|
||||
'ops_event:' || oe.id
|
||||
@@ -2947,7 +2955,8 @@ async fn list_ops_errors(pool: &PgPool, query: OpsErrorQuery) -> StoreResult<Ops
|
||||
" select source, event_id, request_id, attempt_index, client_api_key_ref,
|
||||
component, operation, provider_kind, provider_account_ref,
|
||||
upstream_model_id, upstream_transport,
|
||||
failure_kind, status_code, provider_error_code, client_response_id,
|
||||
failure_kind, client_status_code, upstream_status_code,
|
||||
provider_error_code, client_response_id,
|
||||
upstream_request_id, latency_ms, message, occurrence_count, occurred_at,
|
||||
stable_sort_id
|
||||
from errors e where e.occurred_at >= ",
|
||||
@@ -3046,7 +3055,7 @@ fn push_ops_filter(statement: &mut QueryBuilder<Postgres>, filter: &OpsErrorFilt
|
||||
statement.push_bind(i32::try_from(index).unwrap_or(i32::MAX));
|
||||
}
|
||||
if let Some(status) = filter.status_code {
|
||||
statement.push(" and e.status_code = ");
|
||||
statement.push(" and e.upstream_status_code = ");
|
||||
statement.push_bind(i32::from(status));
|
||||
}
|
||||
if let Some(search) = &filter.search {
|
||||
@@ -3147,7 +3156,8 @@ fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
failure_kind: get(row, "failure_kind")?,
|
||||
status_code: optional_status(row, "status_code")?,
|
||||
client_status_code: optional_status(row, "client_status_code")?,
|
||||
upstream_status_code: optional_status(row, "upstream_status_code")?,
|
||||
provider_error_code: get(row, "provider_error_code")?,
|
||||
client_response_id: get(row, "client_response_id")?,
|
||||
upstream_request_id: get(row, "upstream_request_id")?,
|
||||
|
||||
@@ -1700,10 +1700,8 @@ fn admin_account_status(
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
{
|
||||
AdminAccountStatus::Expired
|
||||
} else if account.availability == ProviderAccountAvailability::Ready {
|
||||
AdminAccountStatus::Active
|
||||
} else {
|
||||
AdminAccountStatus::Attention
|
||||
AdminAccountStatus::Active
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1728,7 +1726,7 @@ fn admin_account_summary(
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX);
|
||||
let attention = u64::try_from(
|
||||
let unavailable = u64::try_from(
|
||||
accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
@@ -1746,7 +1744,7 @@ fn admin_account_summary(
|
||||
total,
|
||||
active,
|
||||
quota_exhausted,
|
||||
attention,
|
||||
unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1797,7 +1795,6 @@ const fn admin_account_status_name(status: AdminAccountStatus) -> &'static str {
|
||||
AdminAccountStatus::QuotaExhausted => "quota_exhausted",
|
||||
AdminAccountStatus::Disabled => "disabled",
|
||||
AdminAccountStatus::Banned => "banned",
|
||||
AdminAccountStatus::Attention => "attention",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! PostgreSQL 账号 cooldown 事实的可丢失 Redis 热缓存。
|
||||
//! 请求调度用的可丢失账号 cooldown Redis 存储。
|
||||
|
||||
use std::{sync::Arc, time::SystemTime};
|
||||
|
||||
@@ -243,7 +243,7 @@ impl ProviderCooldownPort for RedisCredentialCooldownRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL 账号事实到可丢失 cooldown cache 的 write-through adapter。
|
||||
/// 账号状态写入到可丢失 cooldown cache 的桥接 adapter。
|
||||
pub struct CooldownCachingProviderAccountStore {
|
||||
authoritative: Arc<dyn ProviderAccountStore>,
|
||||
cooldowns: Arc<dyn CredentialCooldownRepository>,
|
||||
|
||||
@@ -62,6 +62,57 @@ fn postgres_admin_observability_adapter_implements_terminal_port() {
|
||||
assert_port::<PgAdminObservabilityStore>();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_account_metrics_should_partition_available_and_unavailable_accounts() {
|
||||
let Some(database) = TestDatabase::create("dashboard_account_metrics").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"insert into provider_accounts (
|
||||
id, provider_kind, name, upstream_user_id, authentication_kind,
|
||||
provider_credentials_json, credential_revision, has_refresh_token,
|
||||
access_token_expires_at, enabled, availability,
|
||||
availability_observed_at, created_at, updated_at
|
||||
) values
|
||||
('acct_available', 'openai', 'available', 'user-available', 'oauth',
|
||||
'{}'::jsonb, 1, false, $1 + interval '1 day', true, 'ready', $1, $1, $1),
|
||||
('acct_expired', 'openai', 'expired', 'user-expired', 'oauth',
|
||||
'{}'::jsonb, 1, false, $1 - interval '1 day', true, 'ready', $1, $1, $1),
|
||||
('acct_banned', 'xai', 'banned', 'user-banned', 'oauth',
|
||||
'{}'::jsonb, 1, false, $1 + interval '1 day', true, 'banned', $1, $1, $1),
|
||||
('acct_disabled', 'xai', 'disabled', 'user-disabled', 'oauth',
|
||||
'{}'::jsonb, 1, false, $1 + interval '1 day', false, 'ready', $1, $1, $1)",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed account metric states");
|
||||
let repository = PgObservabilityRepository::new(database.pool.clone());
|
||||
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
|
||||
.expect("dashboard range");
|
||||
|
||||
let metrics = repository
|
||||
.dashboard_summary(range)
|
||||
.await
|
||||
.expect("dashboard summary")
|
||||
.provider_accounts;
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
metrics.total,
|
||||
metrics.enabled,
|
||||
metrics.active,
|
||||
metrics.unavailable,
|
||||
metrics.expired,
|
||||
metrics.disabled,
|
||||
metrics.banned,
|
||||
),
|
||||
(4, 3, 1, 3, 1, 1, 1),
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calculated_usage_billing_facts_keep_only_completed_calculated_costs() {
|
||||
let Some(database) = TestDatabase::create("calculated_usage_billing_facts").await else {
|
||||
@@ -589,6 +640,20 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.await
|
||||
.expect("ops errors");
|
||||
assert_eq!(errors.total, 2);
|
||||
let request_error = errors
|
||||
.items
|
||||
.iter()
|
||||
.find(|error| error.source == "model_request")
|
||||
.expect("request error");
|
||||
assert_eq!(request_error.client_status_code, Some(502));
|
||||
assert_eq!(request_error.upstream_status_code, Some(429));
|
||||
let attempt_error = errors
|
||||
.items
|
||||
.iter()
|
||||
.find(|error| error.source == "ops_event")
|
||||
.expect("attempt error");
|
||||
assert_eq!(attempt_error.client_status_code, None);
|
||||
assert_eq!(attempt_error.upstream_status_code, Some(429));
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -126,13 +126,13 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
beta.availability = ProviderAccountAvailability::Banned;
|
||||
let mut charlie = account("acct_charlie", "user-charlie");
|
||||
charlie.email = Some("charlie@example.invalid".to_owned());
|
||||
let mut attention = account("acct_attention", "user-attention");
|
||||
attention.email = Some("attention@example.invalid".to_owned());
|
||||
attention.availability = ProviderAccountAvailability::Invalid;
|
||||
let mut invalid = account("acct_invalid", "user-invalid");
|
||||
invalid.email = Some("invalid@example.invalid".to_owned());
|
||||
invalid.availability = ProviderAccountAvailability::Invalid;
|
||||
let mut quota_exhausted = account("acct_quota_exhausted", "user-quota-exhausted");
|
||||
quota_exhausted.email = Some("quota-exhausted@example.invalid".to_owned());
|
||||
quota_exhausted.availability = ProviderAccountAvailability::QuotaExhausted;
|
||||
for account in [alpha, beta, charlie, attention, quota_exhausted] {
|
||||
for account in [alpha, beta, charlie, invalid, quota_exhausted] {
|
||||
repository
|
||||
.insert_provider_account(account)
|
||||
.await
|
||||
@@ -214,9 +214,8 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
assert_eq!(usage_page.config_revision.get(), 1);
|
||||
assert_eq!(usage_page.total, 5);
|
||||
assert_eq!(usage_page.summary.total, 5);
|
||||
assert_eq!(usage_page.summary.active, 2);
|
||||
assert_eq!(usage_page.summary.active, 3);
|
||||
assert_eq!(usage_page.summary.quota_exhausted, 1);
|
||||
assert_eq!(usage_page.summary.attention, 1);
|
||||
assert_eq!(
|
||||
usage_page
|
||||
.items
|
||||
@@ -277,19 +276,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
.await
|
||||
.expect("filter banned accounts");
|
||||
assert_eq!(banned.items[0].id, "acct_beta");
|
||||
let attention = store
|
||||
.list_accounts(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
provider_kind: None,
|
||||
search: None,
|
||||
status: Some(AccountStatus::Attention),
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.expect("filter attention accounts");
|
||||
assert_eq!(attention.items[0].id, "acct_attention");
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ use gateway_admin::model::accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountRecord,
|
||||
};
|
||||
use gateway_admin::model::observability::{
|
||||
CalculatedBillingBreakdown, CurrencyCost, DashboardWireAttribute, DashboardWireProfile,
|
||||
DashboardWireTarget, DecimalAmount, ProviderBillingInput,
|
||||
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
|
||||
ProviderBillingInput,
|
||||
};
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwner, AuthorizationOwnerBinding,
|
||||
@@ -57,6 +58,7 @@ use crate::credential::{
|
||||
PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist,
|
||||
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
|
||||
};
|
||||
use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus};
|
||||
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown};
|
||||
|
||||
const PENDING_SCHEMA_VERSION: u64 = 2;
|
||||
@@ -66,6 +68,7 @@ const MAX_PENDING_TEXT_BYTES: usize = 512;
|
||||
pub(crate) struct XaiAdminProvider {
|
||||
provider_kind: ProviderKind,
|
||||
wire_profile: XaiWireProfileState,
|
||||
cli_release: GrokCliReleaseStatus,
|
||||
accounts: Arc<dyn ProviderAccountStore>,
|
||||
repository: GrokCredentialRepository,
|
||||
oauth_config: GrokOAuthConfig,
|
||||
@@ -95,10 +98,12 @@ impl XaiAdminProvider {
|
||||
wire_profile: XaiWireProfileState,
|
||||
accounts: Arc<dyn ProviderAccountStore>,
|
||||
services: XaiAdminServices,
|
||||
cli_release: GrokCliReleaseStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_kind,
|
||||
wire_profile,
|
||||
cli_release,
|
||||
accounts,
|
||||
repository: services.repository,
|
||||
oauth_config: services.oauth_config,
|
||||
@@ -216,34 +221,42 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
}
|
||||
|
||||
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile> {
|
||||
let profile = self.wire_profile.snapshot();
|
||||
let release = dashboard_cli_release(&profile.client_version, self.cli_release.snapshot());
|
||||
Some(DashboardWireProfile {
|
||||
provider: self.provider_kind.as_str().to_owned(),
|
||||
product: "Grok Build".to_owned(),
|
||||
version: self.wire_profile.client_version(),
|
||||
version: profile.client_version.clone(),
|
||||
build: None,
|
||||
target: DashboardWireTarget {
|
||||
os_type: self.wire_profile.target_os(),
|
||||
os_type: profile.target_os.clone(),
|
||||
os_version: "—".to_owned(),
|
||||
arch: self.wire_profile.target_arch(),
|
||||
terminal: self.wire_profile.client_mode(),
|
||||
arch: profile.target_arch.clone(),
|
||||
terminal: profile.client_mode.clone(),
|
||||
},
|
||||
user_agent: self.wire_profile.user_agent(),
|
||||
user_agent: format!(
|
||||
"{}/{} ({}; {})",
|
||||
profile.client_identifier,
|
||||
profile.client_version,
|
||||
profile.target_os,
|
||||
profile.target_arch
|
||||
),
|
||||
attributes: vec![
|
||||
DashboardWireAttribute {
|
||||
label: "客户端标识".to_owned(),
|
||||
value: self.wire_profile.client_identifier(),
|
||||
value: profile.client_identifier,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "运行模式".to_owned(),
|
||||
value: self.wire_profile.client_mode(),
|
||||
value: profile.client_mode,
|
||||
},
|
||||
DashboardWireAttribute {
|
||||
label: "Token 认证".to_owned(),
|
||||
value: "xai-grok-cli".to_owned(),
|
||||
},
|
||||
],
|
||||
verified_at: Some(self.wire_profile.verified_at()),
|
||||
release: None,
|
||||
verified_at: Some(profile.verified_at),
|
||||
release: Some(release),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -771,6 +784,36 @@ fn validate_account_record(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dashboard_cli_release(
|
||||
profile_version: &str,
|
||||
snapshot: GrokCliReleaseSnapshot,
|
||||
) -> DashboardDesktopRelease {
|
||||
let status = if snapshot.checked_at.is_none() {
|
||||
DesktopReleaseStatus::Unchecked
|
||||
} else if snapshot.last_error.is_some() {
|
||||
DesktopReleaseStatus::Failed
|
||||
} else if snapshot.latest_version.as_deref() == Some(profile_version) {
|
||||
DesktopReleaseStatus::Current
|
||||
} else if snapshot.latest_version.is_some() {
|
||||
DesktopReleaseStatus::UpdateAvailable
|
||||
} else {
|
||||
DesktopReleaseStatus::Failed
|
||||
};
|
||||
DashboardDesktopRelease {
|
||||
status,
|
||||
checked_at: snapshot.checked_at,
|
||||
latest_version: snapshot.latest_version,
|
||||
latest_build: None,
|
||||
published_at: None,
|
||||
minimum_system_version: None,
|
||||
hardware_requirements: None,
|
||||
download_url: None,
|
||||
download_size: None,
|
||||
signature_present: None,
|
||||
error: snapshot.last_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, ProviderAdminError> {
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
|
||||
@@ -12,9 +12,10 @@ use gateway_core::engine::credential::{
|
||||
AccountAvailability, CredentialRevision, ProviderAccountId,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderStoreError, provider_refresh_retry_at,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
|
||||
ProviderLeaseRequest, ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest,
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError,
|
||||
provider_refresh_retry_at,
|
||||
};
|
||||
|
||||
use super::catalog::GrokCredentialCatalogService;
|
||||
@@ -36,6 +37,7 @@ const MAX_REFRESH_EXCLUSIONS: usize = 400;
|
||||
const MAX_SECRET_BYTES: usize = 64 * 1_024;
|
||||
const DISCOVERY_NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const REFRESH_RETRY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
const REFRESH_AMBIGUITY_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// 一个到期且已按 revision 读取明文 RT 的 xAI account。
|
||||
pub struct DueGrokCredential {
|
||||
@@ -274,6 +276,7 @@ pub struct GrokCredentialRefreshService {
|
||||
refresher: Arc<dyn GrokCredentialRefresher>,
|
||||
catalog: Arc<GrokCredentialCatalogService>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
}
|
||||
|
||||
@@ -283,6 +286,7 @@ impl GrokCredentialRefreshService {
|
||||
refresher: Arc<dyn GrokCredentialRefresher>,
|
||||
catalog: Arc<GrokCredentialCatalogService>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -290,6 +294,7 @@ impl GrokCredentialRefreshService {
|
||||
refresher,
|
||||
catalog,
|
||||
leases,
|
||||
cooldowns,
|
||||
runtime_policy,
|
||||
}
|
||||
}
|
||||
@@ -297,9 +302,6 @@ impl GrokCredentialRefreshService {
|
||||
pub async fn refresh_due(
|
||||
&self,
|
||||
) -> Result<Vec<GrokCredentialRefreshOutcome>, GrokCredentialRefreshError> {
|
||||
if !self.prepare_cycle_if_due().await? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.refresh_due_excluding(&[]).await
|
||||
}
|
||||
|
||||
@@ -455,8 +457,21 @@ impl GrokCredentialRefreshService {
|
||||
.into_iter()
|
||||
.map(|account_id| GrokCredentialRefreshOutcome::Failed { account_id })
|
||||
.collect::<Vec<_>>();
|
||||
outcomes.reserve(batch.credentials.len());
|
||||
let refreshed = stream::iter(batch.credentials.into_iter().map(|credential| async move {
|
||||
let mut credentials = Vec::with_capacity(batch.credentials.len());
|
||||
for credential in batch.credentials {
|
||||
if !self.runtime_cooldown_active(&credential).await {
|
||||
credentials.push(credential);
|
||||
}
|
||||
}
|
||||
if credentials.is_empty() {
|
||||
return Ok(outcomes);
|
||||
}
|
||||
self.refresher
|
||||
.prepare_cycle()
|
||||
.await
|
||||
.map_err(|_| GrokCredentialRefreshError::Preparation)?;
|
||||
outcomes.reserve(credentials.len());
|
||||
let refreshed = stream::iter(credentials.into_iter().map(|credential| async move {
|
||||
let account_id = credential.account_id.clone();
|
||||
(
|
||||
account_id,
|
||||
@@ -539,14 +554,11 @@ impl GrokCredentialRefreshService {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(GrokRefreshFailure::Ambiguous) => self
|
||||
.persist_terminal_failure(
|
||||
credential,
|
||||
GrokCredentialAvailability::Invalid,
|
||||
"refresh_ambiguous",
|
||||
)
|
||||
.await
|
||||
.map(|_| GrokCredentialRefreshOutcome::Ambiguous { account_id }),
|
||||
Err(GrokRefreshFailure::Ambiguous) => {
|
||||
self.record_runtime_cooldown(&credential, REFRESH_AMBIGUITY_COOLDOWN)
|
||||
.await;
|
||||
Ok(GrokCredentialRefreshOutcome::Ambiguous { account_id })
|
||||
}
|
||||
Err(GrokRefreshFailure::Transient) => {
|
||||
if self
|
||||
.persist_backoff(&credential, "refresh-transient")
|
||||
@@ -644,6 +656,36 @@ impl GrokCredentialRefreshService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn runtime_cooldown_active(&self, credential: &DueGrokCredential) -> bool {
|
||||
let Ok(Some(cooldown)) = self.cooldowns.read(credential.account_id()).await else {
|
||||
return false;
|
||||
};
|
||||
if cooldown.credential_revision() != credential.credential_revision() {
|
||||
if cooldown.credential_revision() < credential.credential_revision() {
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.clear(credential.account_id(), credential.credential_revision())
|
||||
.await;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cooldown.until() > SystemTime::now()
|
||||
}
|
||||
|
||||
async fn record_runtime_cooldown(&self, credential: &DueGrokCredential, duration: Duration) {
|
||||
let cooldown_until = SystemTime::now()
|
||||
.checked_add(duration)
|
||||
.unwrap_or_else(SystemTime::now);
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.put_if_later(ProviderCooldown::new(
|
||||
credential.account_id.clone(),
|
||||
credential.credential_revision,
|
||||
cooldown_until,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn persist_terminal_failure(
|
||||
&self,
|
||||
credential: DueGrokCredential,
|
||||
@@ -721,10 +763,12 @@ impl GrokCredentialRecovery for GrokCredentialRefreshService {
|
||||
GrokCredentialRefreshOutcome::Refreshed { .. }
|
||||
| GrokCredentialRefreshOutcome::Stale { .. },
|
||||
) => GrokCredentialRecoveryOutcome::Recovered,
|
||||
Ok(
|
||||
GrokCredentialRefreshOutcome::Invalidated { .. }
|
||||
| GrokCredentialRefreshOutcome::Ambiguous { .. },
|
||||
) => GrokCredentialRecoveryOutcome::Rejected,
|
||||
Ok(GrokCredentialRefreshOutcome::Invalidated { .. }) => {
|
||||
GrokCredentialRecoveryOutcome::Rejected
|
||||
}
|
||||
Ok(GrokCredentialRefreshOutcome::Ambiguous { .. }) => {
|
||||
GrokCredentialRecoveryOutcome::Unavailable
|
||||
}
|
||||
Ok(
|
||||
GrokCredentialRefreshOutcome::Transient { .. }
|
||||
| GrokCredentialRefreshOutcome::Rejected { .. }
|
||||
|
||||
@@ -7,10 +7,11 @@ use std::time::{Duration, SystemTime};
|
||||
use chrono::Utc;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountCandidate, AccountFeedbackStats, AccountSelectionContext, AccountSelector,
|
||||
ProviderAccount, ProviderAccountId,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderSchedulingLeaseRequest,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
|
||||
ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
|
||||
@@ -26,6 +27,7 @@ use crate::{
|
||||
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const STREAM_INTERRUPTION_COOLDOWN: Duration = Duration::from_secs(30);
|
||||
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
|
||||
/// 仅经 Core account port、TTL catalog cache 和 Redis lease 选择一个 OAuth session。
|
||||
pub struct GrokAccountSessionSelector {
|
||||
@@ -34,6 +36,7 @@ pub struct GrokAccountSessionSelector {
|
||||
catalog_cache: Arc<dyn GrokCredentialCatalogCache>,
|
||||
quota: Arc<GrokCredentialQuotaService>,
|
||||
scheduling: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
account_feedback: Arc<AccountFeedbackStats>,
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ impl GrokAccountSessionSelector {
|
||||
catalog_cache: Arc<dyn GrokCredentialCatalogCache>,
|
||||
quota: Arc<GrokCredentialQuotaService>,
|
||||
scheduling: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
account_feedback: Arc<AccountFeedbackStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -53,6 +57,7 @@ impl GrokAccountSessionSelector {
|
||||
catalog_cache,
|
||||
quota,
|
||||
scheduling,
|
||||
cooldowns,
|
||||
account_feedback,
|
||||
}
|
||||
}
|
||||
@@ -94,8 +99,10 @@ impl GrokAccountSessionSelector {
|
||||
.load_state(&self.provider_kind, &account_ids)
|
||||
.await
|
||||
.map_err(|_| GrokSessionSelectorError::Unavailable)?;
|
||||
let runtime_cooldowns = self.runtime_cooldowns(&catalog_eligible).await;
|
||||
let mut candidates = catalog_eligible
|
||||
.into_iter()
|
||||
.filter(|account| !runtime_cooldowns.contains(account.id()))
|
||||
.map(|account| {
|
||||
let health = self
|
||||
.account_feedback
|
||||
@@ -195,6 +202,43 @@ impl GrokAccountSessionSelector {
|
||||
Err(GrokSessionSelectorError::NoEligibleSession)
|
||||
}
|
||||
}
|
||||
|
||||
async fn runtime_cooldowns(
|
||||
&self,
|
||||
accounts: &[ProviderAccount],
|
||||
) -> std::collections::BTreeSet<ProviderAccountId> {
|
||||
let now = SystemTime::now();
|
||||
let mut cooled = std::collections::BTreeSet::new();
|
||||
for account in accounts {
|
||||
let Ok(Some(cooldown)) = self.cooldowns.read(account.id()).await else {
|
||||
continue;
|
||||
};
|
||||
if cooldown.credential_revision() != account.revision() {
|
||||
if cooldown.credential_revision() < account.revision() {
|
||||
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if cooldown.until() > now {
|
||||
cooled.insert(account.id().clone());
|
||||
}
|
||||
}
|
||||
cooled
|
||||
}
|
||||
|
||||
async fn record_runtime_cooldown(&self, session: &SelectedGrokSession, duration: Duration) {
|
||||
let cooldown_until = SystemTime::now()
|
||||
.checked_add(duration)
|
||||
.unwrap_or_else(SystemTime::now);
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.put_if_later(ProviderCooldown::new(
|
||||
session.account_id().clone(),
|
||||
session.credential_revision(),
|
||||
cooldown_until,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrokAccountSessionSelector {
|
||||
@@ -205,6 +249,7 @@ impl fmt::Debug for GrokAccountSessionSelector {
|
||||
.field("catalog_cache", &"[TTL_CACHE]")
|
||||
.field("quota", &self.quota)
|
||||
.field("scheduling", &"[SCHEDULING_PORT]")
|
||||
.field("cooldowns", &"[COOLDOWN_PORT]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -221,12 +266,12 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
|
||||
) -> GrokCredentialFeedbackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let observed_at = Utc::now();
|
||||
let (availability, reason, cooldown_until) = match failure {
|
||||
GrokCredentialFailure::Unauthorized => (
|
||||
GrokCredentialAvailability::Invalid,
|
||||
"upstream_unauthorized",
|
||||
None,
|
||||
),
|
||||
let persistent = match failure {
|
||||
GrokCredentialFailure::Unauthorized => {
|
||||
self.record_runtime_cooldown(session, UNAUTHORIZED_COOLDOWN)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
GrokCredentialFailure::QuotaExhausted => (
|
||||
GrokCredentialAvailability::QuotaExhausted,
|
||||
"upstream_quota_exhausted",
|
||||
@@ -236,26 +281,16 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
|
||||
let retry_after = retry_after
|
||||
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
|
||||
.min(MAX_RATE_LIMIT_COOLDOWN);
|
||||
let cooldown_until = chrono::Duration::from_std(retry_after)
|
||||
.ok()
|
||||
.and_then(|duration| observed_at.checked_add_signed(duration));
|
||||
(
|
||||
GrokCredentialAvailability::Cooldown,
|
||||
"upstream_rate_limited",
|
||||
cooldown_until,
|
||||
)
|
||||
self.record_runtime_cooldown(session, retry_after).await;
|
||||
return;
|
||||
}
|
||||
GrokCredentialFailure::StreamInterrupted => {
|
||||
let cooldown_until = chrono::Duration::from_std(STREAM_INTERRUPTION_COOLDOWN)
|
||||
.ok()
|
||||
.and_then(|duration| observed_at.checked_add_signed(duration));
|
||||
(
|
||||
GrokCredentialAvailability::Cooldown,
|
||||
"upstream_stream_interrupted",
|
||||
cooldown_until,
|
||||
)
|
||||
self.record_runtime_cooldown(session, STREAM_INTERRUPTION_COOLDOWN)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (availability, reason, cooldown_until) = persistent;
|
||||
let _ = self
|
||||
.repository
|
||||
.update_state(&UpdateGrokCredentialState {
|
||||
|
||||
@@ -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,10 +101,12 @@ pub async fn initialize(
|
||||
OfficialGrokCliReleaseTransport::new().map_err(|_| XaiInitializeError::Transport)?,
|
||||
),
|
||||
));
|
||||
let cli_release_status = cli_release.status();
|
||||
let provider_kind =
|
||||
ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| XaiInitializeError::ProviderKind)?;
|
||||
let accounts: Arc<dyn ProviderAccountStore> = ports.accounts();
|
||||
let leases = ports.leases();
|
||||
let cooldowns = ports.cooldowns();
|
||||
let account_feedback = ports.account_feedback();
|
||||
let runtime_policy = ports.runtime_policy();
|
||||
let repository = GrokCredentialRepository::new(Arc::clone(&accounts));
|
||||
@@ -135,6 +139,7 @@ pub async fn initialize(
|
||||
catalog_cache,
|
||||
Arc::clone("a),
|
||||
Arc::clone(&leases),
|
||||
Arc::clone(&cooldowns),
|
||||
Arc::clone(&account_feedback),
|
||||
));
|
||||
let inference: Arc<dyn GrokInferenceTransport> = Arc::new(
|
||||
@@ -162,6 +167,7 @@ pub async fn initialize(
|
||||
refresher,
|
||||
Arc::clone(&catalog),
|
||||
Arc::clone(&leases),
|
||||
cooldowns,
|
||||
Arc::clone(&runtime_policy),
|
||||
));
|
||||
let credential_recovery: Arc<dyn GrokCredentialRecovery> = refresh.clone();
|
||||
@@ -190,6 +196,7 @@ pub async fn initialize(
|
||||
catalog: Arc::clone(&catalog),
|
||||
runtime_policy,
|
||||
},
|
||||
cli_release_status,
|
||||
));
|
||||
let worker_contributions = provider::worker_contributions(
|
||||
refresh,
|
||||
|
||||
@@ -18,13 +18,19 @@ use gateway_core::engine::provider::{
|
||||
UpstreamTransport,
|
||||
};
|
||||
use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendState};
|
||||
use gateway_core::error::{ContinuationFailure, ProviderError, ProviderErrorKind};
|
||||
use gateway_core::event::{GatewayEvent, ProviderEvent, ProviderResponseObservation, ResponseMeta};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, ContinuationFailure, ProviderError, ProviderErrorKind,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderEvent, ProviderResponseMetadata, ProviderResponseObservation,
|
||||
ProviderResponseTimings, ResponseMeta,
|
||||
};
|
||||
use gateway_core::operation::{
|
||||
Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ModelCapabilities, ProviderCandidate, ProviderKind, SupportLevel, UpstreamModelId,
|
||||
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, SupportLevel,
|
||||
UpstreamModelId,
|
||||
};
|
||||
use gateway_core::task::{
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId,
|
||||
@@ -35,7 +41,6 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use url::Url;
|
||||
|
||||
use crate::GrokCatalogCapabilityEvidence;
|
||||
use crate::XaiWireProfileState;
|
||||
use crate::credential::{
|
||||
GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService,
|
||||
@@ -50,12 +55,14 @@ use crate::transport::{
|
||||
GROK_RESPONSES_URL, GrokCompactionDecodeError, GrokCompactionRequest,
|
||||
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokInferenceChunkStream,
|
||||
GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
|
||||
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokProviderConfigError,
|
||||
GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionSelection,
|
||||
GrokSessionSelector, GrokSessionSelectorError, SelectedGrokSession,
|
||||
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportMetrics,
|
||||
GrokProviderConfigError, GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey,
|
||||
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, SelectedGrokSession,
|
||||
};
|
||||
use crate::{GrokCatalogCapabilityEvidence, GrokCatalogModel};
|
||||
|
||||
const HTTP_SSE_TRANSPORT: &str = "http_sse";
|
||||
const DEFAULT_GROK_MODEL: &str = "grok-4.5";
|
||||
const XAI_SESSION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const XAI_SESSION_OUTPUT_LIMIT: usize = 4_096;
|
||||
const MIN_REASONING_CIPHERTEXT_BYTES: usize = 50;
|
||||
@@ -138,38 +145,15 @@ impl Provider for GrokBuildProvider {
|
||||
async fn query_model_capabilities(
|
||||
&self,
|
||||
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
let models = self.catalog.query_models().await.map_err(|_| {
|
||||
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
})?;
|
||||
let Ok(models) = self.catalog.query_models().await else {
|
||||
return Ok(vec![default_grok_model_capabilities()?]);
|
||||
};
|
||||
if models.is_empty() {
|
||||
return Ok(vec![default_grok_model_capabilities()?]);
|
||||
}
|
||||
Ok(models
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let mut operations = BTreeSet::new();
|
||||
if model.capabilities().responses_api()
|
||||
== GrokCatalogCapabilityEvidence::DeclaredNative
|
||||
{
|
||||
operations.insert(OperationKind::Generate);
|
||||
}
|
||||
let capabilities = ModelCapabilities::new(
|
||||
operations,
|
||||
model
|
||||
.limits()
|
||||
.max_output_tokens()
|
||||
.map(std::num::NonZeroU64::get),
|
||||
)
|
||||
.with_feature(
|
||||
Feature::Reasoning,
|
||||
support(model.capabilities().reasoning_effort()),
|
||||
)
|
||||
.with_feature(
|
||||
Feature::Tools,
|
||||
tool_support(model.capabilities().streaming_tool_calls()),
|
||||
)
|
||||
.with_feature(Feature::Vision, SupportLevel::Unknown)
|
||||
.with_feature(Feature::JsonSchema, SupportLevel::Unknown)
|
||||
.with_feature(Feature::NativeContinuation, SupportLevel::Native);
|
||||
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
|
||||
})
|
||||
.map(compile_grok_model_capabilities)
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -403,6 +387,132 @@ fn tool_support(evidence: GrokCatalogCapabilityEvidence) -> SupportLevel {
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_grok_model_capabilities(model: GrokCatalogModel) -> ProviderModelCapabilities {
|
||||
let mut operations = BTreeSet::new();
|
||||
if model.capabilities().responses_api() == GrokCatalogCapabilityEvidence::DeclaredNative {
|
||||
operations.insert(OperationKind::Generate);
|
||||
}
|
||||
let capabilities = ModelCapabilities::new(
|
||||
operations,
|
||||
model
|
||||
.limits()
|
||||
.max_output_tokens()
|
||||
.map(std::num::NonZeroU64::get),
|
||||
)
|
||||
.with_feature(
|
||||
Feature::Reasoning,
|
||||
support(model.capabilities().reasoning_effort()),
|
||||
)
|
||||
.with_feature(
|
||||
Feature::Tools,
|
||||
tool_support(model.capabilities().streaming_tool_calls()),
|
||||
)
|
||||
.with_feature(Feature::Vision, SupportLevel::Unknown)
|
||||
.with_feature(Feature::JsonSchema, SupportLevel::Unknown)
|
||||
.with_feature(Feature::NativeContinuation, SupportLevel::Native);
|
||||
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
|
||||
.with_presentation(grok_model_presentation(&model))
|
||||
}
|
||||
|
||||
fn default_grok_model_capabilities() -> Result<ProviderModelCapabilities, ProviderError> {
|
||||
let model = UpstreamModelId::new(DEFAULT_GROK_MODEL.to_owned())
|
||||
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
|
||||
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
|
||||
.with_upstream_feature_validation();
|
||||
Ok(ProviderModelCapabilities::new(model, capabilities)
|
||||
.with_presentation(default_grok_model_presentation()))
|
||||
}
|
||||
|
||||
fn default_grok_model_presentation() -> ModelPresentation {
|
||||
ModelPresentation::new(
|
||||
Some("Grok 4.5".to_owned()),
|
||||
Some("xAI Grok 4.5 frontier model with reasoning and vision.".to_owned()),
|
||||
)
|
||||
.with_reasoning(
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
.with_agent_tools(true, true)
|
||||
}
|
||||
|
||||
fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
let slug = model.request_model().as_str();
|
||||
let known_grok_4_5 = matches!(
|
||||
slug,
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
);
|
||||
let reasoning_evidence = model.capabilities().reasoning_effort();
|
||||
let 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 => (
|
||||
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 => (
|
||||
catalog_default_reasoning.or_else(|| Some("medium".to_owned())),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
),
|
||||
GrokCatalogCapabilityEvidence::Unknown => (None, Vec::new()),
|
||||
};
|
||||
let context_window_tokens = model
|
||||
.limits()
|
||||
.context_window_tokens()
|
||||
.map(std::num::NonZeroU64::get)
|
||||
.or(known_grok_4_5.then_some(500_000));
|
||||
let tool_evidence = model.capabilities().streaming_tool_calls();
|
||||
|
||||
ModelPresentation::new(
|
||||
model
|
||||
.display_name()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| known_grok_4_5.then(|| "Grok 4.5".to_owned())),
|
||||
model
|
||||
.metadata()
|
||||
.description()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
known_grok_4_5
|
||||
.then(|| "xAI Grok 4.5 frontier model with reasoning and vision.".to_owned())
|
||||
}),
|
||||
)
|
||||
.with_reasoning(default_reasoning, reasoning_efforts)
|
||||
.with_context_window_tokens(context_window_tokens)
|
||||
.with_image_input(known_grok_4_5)
|
||||
.with_agent_tools(
|
||||
tool_evidence != GrokCatalogCapabilityEvidence::DeclaredUnsupported,
|
||||
tool_evidence == GrokCatalogCapabilityEvidence::DeclaredNative
|
||||
|| (known_grok_4_5 && tool_evidence == GrokCatalogCapabilityEvidence::Unknown),
|
||||
)
|
||||
.with_search_tool(
|
||||
model.capabilities().backend_search() == GrokCatalogCapabilityEvidence::DeclaredNative,
|
||||
)
|
||||
.with_hidden(model.metadata().hidden().unwrap_or(false))
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct XaiSessionState {
|
||||
account_id: String,
|
||||
@@ -692,16 +802,12 @@ fn attach_xai_session_update(
|
||||
.into_iter()
|
||||
.map(XaiReplayItem::ClientInput),
|
||||
);
|
||||
transcript.extend(
|
||||
capture
|
||||
.output_items
|
||||
.into_values()
|
||||
.filter_map(|item| portable_output_item(item, false))
|
||||
.map(|item| XaiReplayItem::AccountOutput {
|
||||
account_id: capture.account_id.clone(),
|
||||
item,
|
||||
}),
|
||||
);
|
||||
transcript.extend(capture.output_items.into_values().map(|item| {
|
||||
XaiReplayItem::AccountOutput {
|
||||
account_id: capture.account_id.clone(),
|
||||
item,
|
||||
}
|
||||
}));
|
||||
let state = XaiSessionState {
|
||||
account_id: capture.account_id,
|
||||
session_id: capture.session_id,
|
||||
@@ -722,7 +828,11 @@ fn capture_output_item(event: &ProviderEvent, capture: &mut GrokSessionCapture)
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
if event_type == Some("response.output_item.done")
|
||||
&& capture.output_items.len() < XAI_SESSION_OUTPUT_LIMIT
|
||||
&& let Some(item) = wire.data().get("item").cloned()
|
||||
&& let Some(item) = wire
|
||||
.data()
|
||||
.get("item")
|
||||
.cloned()
|
||||
.and_then(|item| portable_output_item(item, false))
|
||||
{
|
||||
let index = wire
|
||||
.data()
|
||||
@@ -743,16 +853,17 @@ fn capture_output_item(event: &ProviderEvent, capture: &mut GrokSessionCapture)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|output| !output.is_empty())
|
||||
{
|
||||
capture.output_items.clear();
|
||||
capture.output_items.extend(
|
||||
output
|
||||
.iter()
|
||||
.take(XAI_SESSION_OUTPUT_LIMIT)
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
u32::try_from(index).ok().map(|index| (index, item.clone()))
|
||||
}),
|
||||
);
|
||||
for (index, item) in output.iter().take(XAI_SESSION_OUTPUT_LIMIT).enumerate() {
|
||||
let Some(index) = u32::try_from(index).ok() else {
|
||||
break;
|
||||
};
|
||||
if capture.output_items.contains_key(&index) {
|
||||
continue;
|
||||
}
|
||||
if let Some(item) = portable_output_item(item.clone(), false) {
|
||||
capture.output_items.insert(index, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,17 +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,
|
||||
@@ -1189,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();
|
||||
@@ -1295,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(
|
||||
@@ -1351,8 +1507,10 @@ async fn recover_or_record_failure(
|
||||
.await
|
||||
{
|
||||
GrokCredentialRecoveryOutcome::Recovered => error.with_same_account_retry(),
|
||||
GrokCredentialRecoveryOutcome::Rejected
|
||||
| GrokCredentialRecoveryOutcome::Unavailable => error,
|
||||
GrokCredentialRecoveryOutcome::Rejected => error,
|
||||
GrokCredentialRecoveryOutcome::Unavailable => {
|
||||
record_failure(selector, session, error).await
|
||||
}
|
||||
};
|
||||
}
|
||||
record_failure(selector, session, error).await
|
||||
@@ -1415,10 +1573,23 @@ fn ensure_sent_context(context: &AttemptContext) -> Result<(), ProviderError> {
|
||||
fn map_request_error(error: GrokRequestEncodeError) -> ProviderError {
|
||||
let kind = match error {
|
||||
GrokRequestEncodeError::InvalidProtocolPayload
|
||||
| GrokRequestEncodeError::InvalidRequestNormalization => ProviderErrorKind::InvalidRequest,
|
||||
| GrokRequestEncodeError::InvalidRequestNormalization
|
||||
| GrokRequestEncodeError::InvalidRequestField { .. } => ProviderErrorKind::InvalidRequest,
|
||||
GrokRequestEncodeError::Serialization => ProviderErrorKind::Protocol,
|
||||
};
|
||||
provider_error(kind, UpstreamSendState::NotSent)
|
||||
let provider_error = provider_error(kind, UpstreamSendState::NotSent);
|
||||
if kind != ProviderErrorKind::InvalidRequest {
|
||||
return provider_error;
|
||||
}
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
error.to_string(),
|
||||
Some("invalid_request_normalization".to_owned()),
|
||||
Some("invalid_request_error".to_owned()),
|
||||
);
|
||||
match detail {
|
||||
Ok(detail) => provider_error.with_client_visible_upstream_error(detail),
|
||||
Err(_) => provider_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
|
||||
|
||||
@@ -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,53 +369,62 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
let client = self.client_for(request.binding())?;
|
||||
let (client, client_cache_status) = self.client_for(request.binding())?;
|
||||
let mut builder = client
|
||||
.post(request.endpoint().clone())
|
||||
.body(request.body().to_vec());
|
||||
for header in request.headers() {
|
||||
builder = builder.header(header.name(), header.value().expose());
|
||||
}
|
||||
let response = builder
|
||||
.send()
|
||||
let dns_observer = Arc::new(RequestDnsObserver::default());
|
||||
let headers_started_at = Instant::now();
|
||||
let response = REQUEST_DNS_OBSERVER
|
||||
.scope(Arc::clone(&dns_observer), builder.send())
|
||||
.await
|
||||
.map_err(classify_inference_reqwest_error)?;
|
||||
.map_err(|error| {
|
||||
classify_inference_reqwest_error(error).with_transport_metrics(
|
||||
inference_transport_metrics(client_cache_status, None, &dns_observer),
|
||||
)
|
||||
})?;
|
||||
let transport_metrics = inference_transport_metrics(
|
||||
client_cache_status,
|
||||
Some(elapsed_millis(headers_started_at.elapsed())),
|
||||
&dns_observer,
|
||||
);
|
||||
if !response.status().is_success() {
|
||||
return Err(classify_inference_status(response).await);
|
||||
}
|
||||
if !is_event_stream(&response) {
|
||||
return Err(GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
return Err(classify_inference_status(response)
|
||||
.await
|
||||
.with_transport_metrics(transport_metrics));
|
||||
}
|
||||
let http_version = upstream_http_version(response.version());
|
||||
let status_code = response.status().as_u16();
|
||||
let request_id = upstream_request_id(&response);
|
||||
let body = response.bytes_stream().scan(0_usize, |observed, chunk| {
|
||||
let item = match chunk {
|
||||
Ok(chunk)
|
||||
if observed
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|total| total <= MAX_INFERENCE_BODY_BYTES) =>
|
||||
{
|
||||
*observed += chunk.len();
|
||||
Ok(chunk.to_vec())
|
||||
}
|
||||
Ok(_) => Err(GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
)),
|
||||
Err(error) => Err(classify_inference_stream_error(&error)),
|
||||
};
|
||||
std::future::ready(Some(item))
|
||||
});
|
||||
Ok(GrokInferenceResponse::new(
|
||||
Box::pin(body),
|
||||
http_version,
|
||||
status_code,
|
||||
request_id,
|
||||
))
|
||||
let body = response
|
||||
.bytes_stream()
|
||||
.scan(0_usize, move |observed, chunk| {
|
||||
let item = match chunk {
|
||||
Ok(chunk)
|
||||
if observed
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|total| total <= MAX_INFERENCE_BODY_BYTES) =>
|
||||
{
|
||||
*observed += chunk.len();
|
||||
Ok(chunk.to_vec())
|
||||
}
|
||||
Ok(_) => Err(GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_transport_metrics(transport_metrics)),
|
||||
Err(error) => Err(classify_inference_stream_error(&error)
|
||||
.with_transport_metrics(transport_metrics)),
|
||||
};
|
||||
std::future::ready(Some(item))
|
||||
});
|
||||
Ok(
|
||||
GrokInferenceResponse::new(Box::pin(body), http_version, status_code, request_id)
|
||||
.with_transport_metrics(transport_metrics),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -412,22 +448,24 @@ impl BoundInferenceClients {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_insert(
|
||||
&mut self,
|
||||
binding: &GrokSessionBinding,
|
||||
endpoint_policy: &dyn GrokEndpointPolicy,
|
||||
) -> Result<Client, GrokReqwestTransportBuildError> {
|
||||
fn get(&mut self, binding: &GrokSessionBinding) -> Option<Client> {
|
||||
if let Some(client) = self.by_binding.get(binding).cloned() {
|
||||
self.record_use(binding);
|
||||
return Ok(client);
|
||||
return Some(client);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
let client = match self.unbound_client.take() {
|
||||
Some(client) => client,
|
||||
None => endpoint_policy.build_inference_client(None)?,
|
||||
};
|
||||
fn take_unbound(&mut self) -> Option<Client> {
|
||||
self.unbound_client.take()
|
||||
}
|
||||
|
||||
fn insert_if_absent(&mut self, binding: &GrokSessionBinding, client: Client) -> Client {
|
||||
if let Some(existing) = self.get(binding) {
|
||||
return existing;
|
||||
}
|
||||
self.insert(binding.clone(), client.clone());
|
||||
Ok(client)
|
||||
client
|
||||
}
|
||||
|
||||
fn insert(&mut self, binding: GrokSessionBinding, client: Client) {
|
||||
@@ -454,6 +492,29 @@ fn inference_client_pool_unavailable() -> GrokInferenceTransportError {
|
||||
)
|
||||
}
|
||||
|
||||
fn inference_client_pool_unavailable_for_cache_miss() -> GrokInferenceTransportError {
|
||||
inference_client_pool_unavailable().with_transport_metrics(
|
||||
GrokInferenceTransportMetrics::default()
|
||||
.with_client_cache_status(GrokInferenceClientCacheStatus::Miss),
|
||||
)
|
||||
}
|
||||
|
||||
fn inference_transport_metrics(
|
||||
client_cache_status: GrokInferenceClientCacheStatus,
|
||||
headers_ms: Option<u64>,
|
||||
dns_observer: &RequestDnsObserver,
|
||||
) -> GrokInferenceTransportMetrics {
|
||||
let mut metrics =
|
||||
GrokInferenceTransportMetrics::default().with_client_cache_status(client_cache_status);
|
||||
if let Some(headers_ms) = headers_ms {
|
||||
metrics = metrics.with_headers_ms(headers_ms);
|
||||
}
|
||||
if let Some(dns) = dns_observer.observation() {
|
||||
metrics = metrics.with_dns(dns);
|
||||
}
|
||||
metrics
|
||||
}
|
||||
|
||||
/// 官方 Grok CLI proxy 模型目录 GET transport。
|
||||
pub struct ReqwestGrokModelCatalogTransport {
|
||||
client: Client,
|
||||
@@ -587,8 +648,11 @@ fn build_official_client(
|
||||
.no_proxy()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
|
||||
.pool_max_idle_per_host(128)
|
||||
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
|
||||
.http2_adaptive_window(true)
|
||||
.http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL)
|
||||
.http2_keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.tcp_nodelay(true)
|
||||
.https_only(true)
|
||||
.dns_resolver(Arc::new(StrictDnsResolver::new(dns_policy)?));
|
||||
@@ -632,6 +696,24 @@ struct StrictDnsResolver {
|
||||
trusted_doh: TrustedDohResolver,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RequestDnsObserver {
|
||||
observation: OnceLock<GrokInferenceDnsObservation>,
|
||||
}
|
||||
|
||||
impl RequestDnsObserver {
|
||||
fn record(&self, source: GrokInferenceDnsSource, duration: Duration) {
|
||||
let _ = self.observation.set(GrokInferenceDnsObservation::new(
|
||||
source,
|
||||
elapsed_millis(duration),
|
||||
));
|
||||
}
|
||||
|
||||
fn observation(&self) -> Option<GrokInferenceDnsObservation> {
|
||||
self.observation.get().copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl StrictDnsResolver {
|
||||
fn new(policy: GrokDnsResolutionPolicy) -> Result<Self, GrokReqwestTransportBuildError> {
|
||||
Ok(Self {
|
||||
@@ -653,7 +735,9 @@ impl Resolve for StrictDnsResolver {
|
||||
}
|
||||
let policy = self.policy;
|
||||
let trusted_doh = self.trusted_doh.clone();
|
||||
let observer = REQUEST_DNS_OBSERVER.try_with(Arc::clone).ok();
|
||||
Box::pin(async move {
|
||||
let started_at = Instant::now();
|
||||
let system_addresses = match tokio::net::lookup_host((requested_host.as_str(), 0)).await
|
||||
{
|
||||
Ok(addresses) => addresses.collect::<Vec<_>>(),
|
||||
@@ -663,29 +747,43 @@ impl Resolve for StrictDnsResolver {
|
||||
.iter()
|
||||
.map(SocketAddr::ip)
|
||||
.collect::<Vec<_>>();
|
||||
match policy
|
||||
let (source, result) = match policy
|
||||
.plan_system_resolution(&requested_host, &system_ips)
|
||||
.map_err(|_| safe_dns_error("DNS resolution rejected"))?
|
||||
{
|
||||
GrokDnsResolutionPlan::System => {
|
||||
Ok(Box::new(system_addresses.into_iter()) as Addrs)
|
||||
}
|
||||
GrokDnsResolutionPlan::System => (
|
||||
GrokInferenceDnsSource::System,
|
||||
Ok(Box::new(system_addresses.into_iter()) as Addrs),
|
||||
),
|
||||
GrokDnsResolutionPlan::TrustedDoh => {
|
||||
let addresses = trusted_doh.resolve(&requested_host).await?;
|
||||
policy
|
||||
.validate_trusted_doh_resolution(&requested_host, &addresses)
|
||||
.map_err(|_| safe_dns_error("trusted DNS result rejected"))?;
|
||||
Ok(Box::new(
|
||||
addresses
|
||||
.into_iter()
|
||||
.map(|address| SocketAddr::new(address, 0)),
|
||||
) as Addrs)
|
||||
let result = match trusted_doh.resolve(&requested_host).await {
|
||||
Ok(addresses) => policy
|
||||
.validate_trusted_doh_resolution(&requested_host, &addresses)
|
||||
.map_err(|_| safe_dns_error("trusted DNS result rejected"))
|
||||
.map(|()| {
|
||||
Box::new(
|
||||
addresses
|
||||
.into_iter()
|
||||
.map(|address| SocketAddr::new(address, 0)),
|
||||
) as Addrs
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
(GrokInferenceDnsSource::TrustedDoh, result)
|
||||
}
|
||||
};
|
||||
if let Some(observer) = observer {
|
||||
observer.record(source, started_at.elapsed());
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_millis(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrustedDohResolver {
|
||||
client: Client,
|
||||
@@ -1109,15 +1207,6 @@ fn retry_after(response: &Response) -> Option<Duration> {
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
fn is_event_stream(response: &Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(';').next())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("text/event-stream"))
|
||||
}
|
||||
|
||||
fn is_json_response(response: &Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
|
||||
@@ -145,18 +145,82 @@ impl GrokCliReleaseTransport for OfficialGrokCliReleaseTransport {
|
||||
pub struct GrokCliReleaseService {
|
||||
profile: XaiWireProfileState,
|
||||
transport: Arc<dyn GrokCliReleaseTransport>,
|
||||
status: GrokCliReleaseStatus,
|
||||
}
|
||||
|
||||
impl GrokCliReleaseService {
|
||||
#[must_use]
|
||||
pub fn new(profile: XaiWireProfileState, transport: Arc<dyn GrokCliReleaseTransport>) -> Self {
|
||||
Self { profile, transport }
|
||||
Self {
|
||||
profile,
|
||||
transport,
|
||||
status: GrokCliReleaseStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn status(&self) -> GrokCliReleaseStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<String, GrokCliReleaseError> {
|
||||
let version = self.transport.fetch().await?;
|
||||
self.profile.update_client_version(&version);
|
||||
Ok(version)
|
||||
let checked_at = Utc::now();
|
||||
let result = self.transport.fetch().await;
|
||||
match result {
|
||||
Ok(version) => {
|
||||
self.profile.update_client_version(&version);
|
||||
self.status.record_success(checked_at, version.clone());
|
||||
Ok(version)
|
||||
}
|
||||
Err(error) => {
|
||||
self.status.record_failure(checked_at, &error);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近一次官方 Grok CLI 发布检查结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GrokCliReleaseSnapshot {
|
||||
pub(crate) checked_at: Option<DateTime<Utc>>,
|
||||
pub(crate) latest_version: Option<String>,
|
||||
pub(crate) last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 内共享的 Grok CLI 发布检查观察状态。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct GrokCliReleaseStatus {
|
||||
snapshot: Arc<RwLock<GrokCliReleaseSnapshot>>,
|
||||
}
|
||||
|
||||
impl GrokCliReleaseStatus {
|
||||
#[must_use]
|
||||
pub(crate) fn snapshot(&self) -> GrokCliReleaseSnapshot {
|
||||
self.snapshot
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn record_success(&self, checked_at: DateTime<Utc>, latest_version: String) {
|
||||
*self
|
||||
.snapshot
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = GrokCliReleaseSnapshot {
|
||||
checked_at: Some(checked_at),
|
||||
latest_version: Some(latest_version),
|
||||
last_error: None,
|
||||
};
|
||||
}
|
||||
|
||||
fn record_failure(&self, checked_at: DateTime<Utc>, error: &GrokCliReleaseError) {
|
||||
let mut snapshot = self
|
||||
.snapshot
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
snapshot.checked_at = Some(checked_at);
|
||||
snapshot.last_error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,3 +255,91 @@ pub enum GrokCliReleaseError {
|
||||
#[error("Grok CLI release version is invalid")]
|
||||
InvalidVersion,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ReleaseTransport {
|
||||
outcomes: Mutex<VecDeque<Result<String, GrokCliReleaseError>>>,
|
||||
}
|
||||
|
||||
impl ReleaseTransport {
|
||||
fn new(outcomes: impl IntoIterator<Item = Result<String, GrokCliReleaseError>>) -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GrokCliReleaseTransport for ReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, GrokCliReleaseError>> {
|
||||
let outcome = self
|
||||
.outcomes
|
||||
.lock()
|
||||
.expect("release outcomes lock")
|
||||
.pop_front()
|
||||
.expect("release outcome");
|
||||
Box::pin(async move { outcome })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_refresh_should_update_profile_and_observation() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([Ok("0.2.112".to_owned())])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
assert_eq!(service.refresh().await.expect("release"), "0.2.112");
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert!(snapshot.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_release_refresh_should_keep_last_successful_version() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([
|
||||
Ok("0.2.112".to_owned()),
|
||||
Err(GrokCliReleaseError::InvalidDocument),
|
||||
])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
service.refresh().await.expect("first release");
|
||||
assert!(matches!(
|
||||
service.refresh().await,
|
||||
Err(GrokCliReleaseError::InvalidDocument)
|
||||
));
|
||||
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert_eq!(
|
||||
snapshot.last_error.as_deref(),
|
||||
Some("Grok CLI release document is invalid")
|
||||
);
|
||||
}
|
||||
|
||||
fn wire_profile() -> XaiWireProfileState {
|
||||
XaiWireProfileState::new(XaiWireProfileConfig {
|
||||
client_identifier: "grok-shell".to_owned(),
|
||||
client_version: "0.2.106".to_owned(),
|
||||
client_mode: "headless".to_owned(),
|
||||
target_os: "linux".to_owned(),
|
||||
target_arch: "x86_64".to_owned(),
|
||||
verified_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
@@ -279,6 +279,18 @@ pub enum GrokRequestEncodeError {
|
||||
/// Responses compatibility fields could not be normalized safely.
|
||||
#[error("Grok Build request normalization failed")]
|
||||
InvalidRequestNormalization,
|
||||
/// 请求中的具体字段无法安全转换为 Grok Build 接受的形态。
|
||||
#[error("Grok Build request field `{field}` could not be normalized safely")]
|
||||
InvalidRequestField { field: &'static str },
|
||||
}
|
||||
|
||||
impl GrokRequestEncodeError {
|
||||
fn at_field(self, field: &'static str) -> Self {
|
||||
match self {
|
||||
Self::InvalidRequestNormalization => Self::InvalidRequestField { field },
|
||||
error => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_account_identity(body: &mut Map<String, Value>) {
|
||||
@@ -548,11 +560,12 @@ fn normalize_responses_request(
|
||||
}
|
||||
let text = text
|
||||
.as_object_mut()
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "text" })?;
|
||||
if text.get("format").is_none_or(Value::is_null) {
|
||||
text.insert(
|
||||
"format".to_owned(),
|
||||
normalize_response_format(response_format)?,
|
||||
normalize_response_format(response_format)
|
||||
.map_err(|error| error.at_field("response_format"))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -711,20 +724,22 @@ impl GrokResponseTransform {
|
||||
.stream_calls
|
||||
.get_mut(&primary)
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
if matches!(
|
||||
state.identity.kind,
|
||||
ToolKind::ToolSearch | ToolKind::Custom | ToolKind::ApplyPatch
|
||||
) {
|
||||
state.arguments.push_str(
|
||||
value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if state.identity.kind == ToolKind::Custom {
|
||||
state.last_delta = value.as_object().cloned();
|
||||
match state.identity.kind {
|
||||
ToolKind::Custom => {
|
||||
state.arguments.push_str(
|
||||
value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
state.last_delta = value.as_object().cloned().map(|mut payload| {
|
||||
payload.remove("delta");
|
||||
payload
|
||||
});
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
ToolKind::ToolSearch | ToolKind::ApplyPatch => return Ok(Vec::new()),
|
||||
ToolKind::Function => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +782,10 @@ impl GrokResponseTransform {
|
||||
&input,
|
||||
),
|
||||
});
|
||||
if let Some(state) = self.stream_calls.get_mut(&primary) {
|
||||
state.arguments.clear();
|
||||
state.last_delta = None;
|
||||
}
|
||||
return Ok(output);
|
||||
}
|
||||
ToolKind::Function => {}
|
||||
@@ -783,10 +802,17 @@ impl GrokResponseTransform {
|
||||
return self.rewrite_apply_patch_done_event(value);
|
||||
}
|
||||
|
||||
let completed_call = (event_type == "response.output_item.done")
|
||||
.then(|| value.get("item").and_then(Value::as_object))
|
||||
.flatten()
|
||||
.and_then(|item| self.primary_stream_call(item));
|
||||
self.rewrite_response_value(&mut value)?;
|
||||
if let Some(response) = value.get_mut("response").and_then(Value::as_object_mut) {
|
||||
self.restore_visible_tools(response);
|
||||
}
|
||||
if let Some(primary) = completed_call {
|
||||
self.take_stream_call(&primary);
|
||||
}
|
||||
Ok(vec![GrokTransformedWireEvent {
|
||||
event_type: event_type.to_owned(),
|
||||
value,
|
||||
@@ -847,6 +873,18 @@ impl GrokResponseTransform {
|
||||
Some(primary)
|
||||
}
|
||||
|
||||
fn primary_stream_call(&self, item: &Map<String, Value>) -> Option<String> {
|
||||
["id", "call_id"]
|
||||
.into_iter()
|
||||
.filter_map(|field| item.get(field).and_then(Value::as_str))
|
||||
.find_map(|key| self.stream_keys.get(key).cloned())
|
||||
}
|
||||
|
||||
fn take_stream_call(&mut self, primary: &str) -> Option<StreamCallState> {
|
||||
self.stream_keys.retain(|_, owner| owner != primary);
|
||||
self.stream_calls.remove(primary)
|
||||
}
|
||||
|
||||
fn rewrite_response_value(&self, value: &mut Value) -> Result<(), GrokRequestEncodeError> {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
@@ -953,15 +991,11 @@ impl GrokResponseTransform {
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
let primary = ["id", "call_id"]
|
||||
.into_iter()
|
||||
.find_map(|field| original_item.get(field).and_then(Value::as_str))
|
||||
.and_then(|key| self.stream_keys.get(key))
|
||||
.cloned();
|
||||
let mut added = primary
|
||||
.as_ref()
|
||||
.and_then(|key| self.stream_calls.get(key))
|
||||
.and_then(|state| state.added_payload.clone())
|
||||
let state = self
|
||||
.primary_stream_call(&original_item)
|
||||
.and_then(|primary| self.take_stream_call(&primary));
|
||||
let mut added = state
|
||||
.and_then(|state| state.added_payload)
|
||||
.unwrap_or_else(|| {
|
||||
Map::from_iter([(
|
||||
"type".to_owned(),
|
||||
@@ -1148,20 +1182,27 @@ impl ToolNormalizer {
|
||||
mut self,
|
||||
payload: &mut Map<String, Value>,
|
||||
) -> Result<GrokResponseTransform, GrokRequestEncodeError> {
|
||||
let (tools, had_tools) = optional_array(payload.get("tools"))?;
|
||||
let (tools, had_tools) =
|
||||
optional_array(payload.get("tools")).map_err(|error| error.at_field("tools"))?;
|
||||
if had_tools {
|
||||
self.response.visible_tools.clone_from(&tools);
|
||||
}
|
||||
let client_search = inspect_tool_search(&tools)?;
|
||||
self.normalize_client_search_parallel(payload, client_search)?;
|
||||
let client_search = inspect_tool_search(&tools).map_err(|error| error.at_field("tools"))?;
|
||||
self.normalize_client_search_parallel(payload, client_search)
|
||||
.map_err(|error| error.at_field("parallel_tool_calls"))?;
|
||||
|
||||
let mut normalized_tools = Vec::with_capacity(tools.len());
|
||||
for raw_tool in &tools {
|
||||
normalized_tools.extend(self.normalize_tool(raw_tool, "", client_search, false)?);
|
||||
normalized_tools.extend(
|
||||
self.normalize_tool(raw_tool, "", client_search, false)
|
||||
.map_err(|error| error.at_field("tools"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(Value::Array(items)) = payload.get("input") {
|
||||
let normalized = self.normalize_input_items(items)?;
|
||||
let normalized = self
|
||||
.normalize_input_items(items)
|
||||
.map_err(|error| error.at_field("input"))?;
|
||||
normalized_tools.extend(normalized.loaded_tools);
|
||||
self.response.visible_tools.extend(normalized.visible_tools);
|
||||
payload.insert("input".to_owned(), Value::Array(normalized.items));
|
||||
@@ -1169,11 +1210,14 @@ impl ToolNormalizer {
|
||||
.get("input")
|
||||
.is_some_and(|input| !input.is_null() && !input.is_string())
|
||||
{
|
||||
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
|
||||
return Err(GrokRequestEncodeError::InvalidRequestField { field: "input" });
|
||||
}
|
||||
|
||||
if self.client_search_tool.is_some() {
|
||||
normalized_tools.push(Value::Object(self.build_client_search_function()?));
|
||||
normalized_tools.push(Value::Object(
|
||||
self.build_client_search_function()
|
||||
.map_err(|error| error.at_field("tools"))?,
|
||||
));
|
||||
}
|
||||
normalized_tools = dedupe_normalized_tools(normalized_tools);
|
||||
if normalized_tools.is_empty() {
|
||||
@@ -1184,7 +1228,8 @@ impl ToolNormalizer {
|
||||
} else {
|
||||
payload.insert("tools".to_owned(), Value::Array(normalized_tools.clone()));
|
||||
}
|
||||
self.normalize_tool_choice(payload, &normalized_tools)?;
|
||||
self.normalize_tool_choice(payload, &normalized_tools)
|
||||
.map_err(|error| error.at_field("tool_choice"))?;
|
||||
Ok(self.response)
|
||||
}
|
||||
|
||||
@@ -1299,6 +1344,11 @@ impl ToolNormalizer {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut converted = without_defer_loading(tool);
|
||||
if let Some(parameters) = converted.get("parameters").cloned()
|
||||
&& let Some(normalized) = normalize_function_parameters_root(¶meters)?
|
||||
{
|
||||
converted.insert("parameters".to_owned(), normalized);
|
||||
}
|
||||
let alias = self.alias(ToolIdentity::new(ToolKind::Function, namespace, name));
|
||||
converted.insert("name".to_owned(), Value::String(alias));
|
||||
Ok(vec![Value::Object(converted)])
|
||||
@@ -1448,6 +1498,136 @@ fn optional_array(value: Option<&Value>) -> Result<(Vec<Value>, bool), GrokReque
|
||||
}
|
||||
}
|
||||
|
||||
/// Grok Build 要求 function parameters 的根节点必为 object;Codex 会为可选参数
|
||||
/// 生成 `object | null`,这里只移除根节点 nullability,嵌套字段保持原样。
|
||||
fn normalize_function_parameters_root(
|
||||
value: &Value,
|
||||
) -> Result<Option<Value>, GrokRequestEncodeError> {
|
||||
let Some(schema) = value.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut normalized = schema.clone();
|
||||
let mut changed = false;
|
||||
|
||||
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
|
||||
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
|
||||
if removed_null {
|
||||
let remaining = types
|
||||
.into_iter()
|
||||
.filter(|value| value.as_str() != Some("null"))
|
||||
.collect::<Vec<_>>();
|
||||
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
|
||||
return Err(invalid_function_parameters_root());
|
||||
}
|
||||
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for keyword in ["anyOf", "oneOf"] {
|
||||
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
|
||||
continue;
|
||||
};
|
||||
let removed_null = branches.iter().any(is_null_only_schema);
|
||||
if !removed_null {
|
||||
continue;
|
||||
}
|
||||
let remaining = branches
|
||||
.into_iter()
|
||||
.filter(|branch| !is_null_only_schema(branch))
|
||||
.collect::<Vec<_>>();
|
||||
if remaining.is_empty()
|
||||
|| remaining.iter().any(|branch| {
|
||||
branch.as_object().is_none_or(|branch| {
|
||||
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(invalid_function_parameters_root());
|
||||
}
|
||||
if remaining.len() == 1 && normalized.len() == 1 {
|
||||
normalized = remaining[0]
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(invalid_function_parameters_root)?;
|
||||
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
|
||||
} else {
|
||||
normalized.insert(keyword.to_owned(), Value::Array(remaining));
|
||||
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
Ok(changed.then_some(Value::Object(normalized)))
|
||||
}
|
||||
|
||||
fn is_null_only_schema(value: &Value) -> bool {
|
||||
let Some(schema) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
match schema.get("type") {
|
||||
Some(Value::String(kind)) => kind == "null",
|
||||
Some(Value::Array(types)) if !types.is_empty() => {
|
||||
types.iter().all(|kind| kind.as_str() == Some("null"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_object_root_schema(
|
||||
schema: &Map<String, Value>,
|
||||
root: &Map<String, Value>,
|
||||
visited: &mut BTreeSet<String>,
|
||||
) -> bool {
|
||||
match schema.get("type") {
|
||||
Some(Value::String(kind)) => return kind == "object",
|
||||
Some(Value::Array(types)) if !types.is_empty() => {
|
||||
return types.iter().all(|kind| kind.as_str() == Some("object"));
|
||||
}
|
||||
Some(_) => return false,
|
||||
None => {}
|
||||
}
|
||||
if schema.contains_key("properties") {
|
||||
return true;
|
||||
}
|
||||
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
if !visited.insert(reference.to_owned()) {
|
||||
return false;
|
||||
}
|
||||
resolve_local_schema_ref(root, reference)
|
||||
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
|
||||
}
|
||||
|
||||
fn resolve_local_schema_ref<'a>(
|
||||
root: &'a Map<String, Value>,
|
||||
reference: &str,
|
||||
) -> Option<&'a Map<String, Value>> {
|
||||
if reference == "#" {
|
||||
return Some(root);
|
||||
}
|
||||
let path = reference.strip_prefix("#/")?;
|
||||
let mut segments = path.split('/');
|
||||
let first = decode_json_pointer_segment(segments.next()?);
|
||||
let mut current = root.get(&first)?;
|
||||
for segment in segments {
|
||||
let segment = decode_json_pointer_segment(segment);
|
||||
current = current.as_object()?.get(&segment)?;
|
||||
}
|
||||
current.as_object()
|
||||
}
|
||||
|
||||
fn decode_json_pointer_segment(segment: &str) -> String {
|
||||
segment.replace("~1", "/").replace("~0", "~")
|
||||
}
|
||||
|
||||
const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
|
||||
GrokRequestEncodeError::InvalidRequestField {
|
||||
field: "tools[].parameters",
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_tool_search(tools: &[Value]) -> Result<bool, GrokRequestEncodeError> {
|
||||
let mut client_search = false;
|
||||
let mut server_search = false;
|
||||
|
||||
@@ -79,6 +79,117 @@ impl fmt::Debug for GrokInferenceRequest {
|
||||
pub type GrokInferenceChunkStream =
|
||||
Pin<Box<dyn Stream<Item = Result<Vec<u8>, GrokInferenceTransportError>> + Send + 'static>>;
|
||||
|
||||
/// Whether the account-isolated inference client was already cached.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GrokInferenceClientCacheStatus {
|
||||
/// The binding reused its existing account-isolated client.
|
||||
Hit,
|
||||
/// The binding was absent when the request first checked the cache.
|
||||
Miss,
|
||||
}
|
||||
|
||||
impl GrokInferenceClientCacheStatus {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hit => "hit",
|
||||
Self::Miss => "miss",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolver that supplied the addresses used by the request.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GrokInferenceDnsSource {
|
||||
/// The system resolver returned an entirely public address set.
|
||||
System,
|
||||
/// The system result was unusable and the trusted DoH fallback supplied addresses.
|
||||
TrustedDoh,
|
||||
}
|
||||
|
||||
impl GrokInferenceDnsSource {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::TrustedDoh => "trusted_doh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DNS work observed while opening an upstream connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GrokInferenceDnsObservation {
|
||||
source: GrokInferenceDnsSource,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
impl GrokInferenceDnsObservation {
|
||||
#[must_use]
|
||||
pub const fn new(source: GrokInferenceDnsSource, duration_ms: u64) -> Self {
|
||||
Self {
|
||||
source,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn source(self) -> GrokInferenceDnsSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn duration_ms(self) -> u64 {
|
||||
self.duration_ms
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-cardinality transport timings and pool facts for one inference request.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct GrokInferenceTransportMetrics {
|
||||
headers_ms: Option<u64>,
|
||||
client_cache_status: Option<GrokInferenceClientCacheStatus>,
|
||||
dns: Option<GrokInferenceDnsObservation>,
|
||||
}
|
||||
|
||||
impl GrokInferenceTransportMetrics {
|
||||
#[must_use]
|
||||
pub const fn with_headers_ms(mut self, headers_ms: u64) -> Self {
|
||||
self.headers_ms = Some(headers_ms);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_client_cache_status(
|
||||
mut self,
|
||||
status: GrokInferenceClientCacheStatus,
|
||||
) -> Self {
|
||||
self.client_cache_status = Some(status);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_dns(mut self, dns: GrokInferenceDnsObservation) -> Self {
|
||||
self.dns = Some(dns);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn headers_ms(self) -> Option<u64> {
|
||||
self.headers_ms
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn client_cache_status(self) -> Option<GrokInferenceClientCacheStatus> {
|
||||
self.client_cache_status
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn dns(self) -> Option<GrokInferenceDnsObservation> {
|
||||
self.dns
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepted inference response. Non-success HTTP responses must be returned as
|
||||
/// [`GrokInferenceTransportError`] instead.
|
||||
pub struct GrokInferenceResponse {
|
||||
@@ -86,6 +197,7 @@ pub struct GrokInferenceResponse {
|
||||
http_version: UpstreamHttpVersion,
|
||||
status_code: u16,
|
||||
request_id: Option<SafeUpstreamValue>,
|
||||
transport_metrics: GrokInferenceTransportMetrics,
|
||||
}
|
||||
|
||||
impl GrokInferenceResponse {
|
||||
@@ -102,9 +214,19 @@ impl GrokInferenceResponse {
|
||||
http_version,
|
||||
status_code,
|
||||
request_id,
|
||||
transport_metrics: GrokInferenceTransportMetrics::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_transport_metrics(
|
||||
mut self,
|
||||
transport_metrics: GrokInferenceTransportMetrics,
|
||||
) -> Self {
|
||||
self.transport_metrics = transport_metrics;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn http_version(&self) -> UpstreamHttpVersion {
|
||||
self.http_version
|
||||
@@ -120,6 +242,11 @@ impl GrokInferenceResponse {
|
||||
self.request_id.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn transport_metrics(&self) -> GrokInferenceTransportMetrics {
|
||||
self.transport_metrics
|
||||
}
|
||||
|
||||
pub fn into_body(self) -> GrokInferenceChunkStream {
|
||||
self.body
|
||||
}
|
||||
@@ -132,6 +259,7 @@ impl fmt::Debug for GrokInferenceResponse {
|
||||
.field("http_version", &self.http_version)
|
||||
.field("status_code", &self.status_code)
|
||||
.field("request_id", &self.request_id)
|
||||
.field("transport_metrics", &self.transport_metrics)
|
||||
.field("body", &"[SSE STREAM]")
|
||||
.finish()
|
||||
}
|
||||
@@ -174,6 +302,7 @@ pub struct GrokInferenceTransportError {
|
||||
http_version: Option<UpstreamHttpVersion>,
|
||||
request_id: Option<SafeUpstreamValue>,
|
||||
upstream_code: Option<SafeUpstreamValue>,
|
||||
transport_metrics: GrokInferenceTransportMetrics,
|
||||
credential_recovery_required: bool,
|
||||
sensitive_context_redacted: bool,
|
||||
}
|
||||
@@ -190,6 +319,11 @@ impl GrokInferenceTransportError {
|
||||
http_version: None,
|
||||
request_id: None,
|
||||
upstream_code: None,
|
||||
transport_metrics: GrokInferenceTransportMetrics {
|
||||
headers_ms: None,
|
||||
client_cache_status: None,
|
||||
dns: None,
|
||||
},
|
||||
credential_recovery_required: false,
|
||||
sensitive_context_redacted: false,
|
||||
}
|
||||
@@ -229,6 +363,15 @@ impl GrokInferenceTransportError {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_transport_metrics(
|
||||
mut self,
|
||||
transport_metrics: GrokInferenceTransportMetrics,
|
||||
) -> Self {
|
||||
self.transport_metrics = transport_metrics;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_credential_recovery(mut self) -> Self {
|
||||
self.credential_recovery_required = true;
|
||||
@@ -281,6 +424,11 @@ impl GrokInferenceTransportError {
|
||||
self.upstream_code.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn transport_metrics(&self) -> GrokInferenceTransportMetrics {
|
||||
self.transport_metrics
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn requires_credential_recovery(&self) -> bool {
|
||||
self.credential_recovery_required
|
||||
@@ -304,6 +452,7 @@ impl fmt::Debug for GrokInferenceTransportError {
|
||||
.field("http_version", &self.http_version)
|
||||
.field("request_id", &self.request_id)
|
||||
.field("upstream_code", &self.upstream_code)
|
||||
.field("transport_metrics", &self.transport_metrics)
|
||||
.field(
|
||||
"credential_recovery_required",
|
||||
&self.credential_recovery_required,
|
||||
|
||||
@@ -9,7 +9,9 @@ use futures::{StreamExt as _, future::BoxFuture};
|
||||
use gateway_admin::model::accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountRecord,
|
||||
};
|
||||
use gateway_admin::model::observability::{CurrencyCost, ProviderBillingInput};
|
||||
use gateway_admin::model::observability::{
|
||||
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
|
||||
};
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
CompleteAuthorization, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
|
||||
@@ -281,7 +283,9 @@ async fn xai_admin_provider_validates_known_billing_breakdown() {
|
||||
assert_eq!(profile.product, "Grok Build");
|
||||
assert_eq!(profile.version, "0.2.106");
|
||||
assert_eq!(profile.user_agent, "grok-shell/0.2.106 (linux; x86_64)");
|
||||
assert!(profile.release.is_none());
|
||||
let release = profile.release.expect("release status");
|
||||
assert_eq!(release.status, DesktopReleaseStatus::Unchecked);
|
||||
assert!(release.checked_at.is_none());
|
||||
|
||||
let billing = admin
|
||||
.calculated_billing(&ProviderBillingInput {
|
||||
|
||||
@@ -20,8 +20,8 @@ use provider_xai::{
|
||||
};
|
||||
|
||||
use crate::support::{
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object,
|
||||
runtime_policy, seed_input,
|
||||
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input,
|
||||
credential_object, runtime_policy, seed_input,
|
||||
};
|
||||
|
||||
const OFFICIAL_FIXTURE: &[u8] =
|
||||
@@ -169,11 +169,13 @@ async fn fixture_many(
|
||||
available: lease_available,
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::default());
|
||||
let service = GrokCredentialRefreshService::new(
|
||||
repository.clone(),
|
||||
refresher_port,
|
||||
catalog,
|
||||
leases,
|
||||
cooldowns,
|
||||
runtime_policy(),
|
||||
);
|
||||
(store, repository, refresher, service)
|
||||
@@ -380,10 +382,11 @@ async fn banned_failure_marks_account_banned() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
|
||||
async fn ambiguous_refresh_uses_runtime_cooldown_without_persisting_account_state() {
|
||||
let input = due_input("ambiguous");
|
||||
let id = input.account_id.clone();
|
||||
let (store, _, _, service) = fixture(input, [Err(GrokRefreshFailure::Ambiguous)], true).await;
|
||||
let (store, _, refresher, service) =
|
||||
fixture(input, [Err(GrokRefreshFailure::Ambiguous)], true).await;
|
||||
let outcomes = service.refresh_due().await.expect("refresh");
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
@@ -391,8 +394,9 @@ async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
|
||||
));
|
||||
assert_eq!(
|
||||
store.account(&id).expect("account").availability(),
|
||||
AccountAvailability::Invalid
|
||||
AccountAvailability::Unknown
|
||||
);
|
||||
assert_eq!(store.account(&id).expect("account").revision().get(), 1);
|
||||
assert!(
|
||||
service
|
||||
.refresh_due()
|
||||
@@ -400,6 +404,7 @@ async fn ambiguous_refresh_fails_closed_and_is_not_retried() {
|
||||
.expect("second cycle")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -23,7 +23,8 @@ use provider_xai::{
|
||||
};
|
||||
|
||||
use crate::support::{
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id, create_input, seed_input,
|
||||
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id,
|
||||
create_input, seed_input,
|
||||
};
|
||||
|
||||
struct SchedulingCoordinator {
|
||||
@@ -88,6 +89,7 @@ struct SelectorFixture {
|
||||
cache: Arc<MemoryGrokCatalogCache>,
|
||||
selector: GrokAccountSessionSelector,
|
||||
coordinator: Arc<SchedulingCoordinator>,
|
||||
cooldowns: Arc<MemoryCooldownPort>,
|
||||
feedback: Arc<AccountFeedbackStats>,
|
||||
}
|
||||
|
||||
@@ -137,6 +139,7 @@ impl SelectorFixture {
|
||||
signals: Mutex::new(signals),
|
||||
denied: Mutex::new(BTreeSet::new()),
|
||||
});
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::default());
|
||||
let catalog_cache: Arc<dyn GrokCredentialCatalogCache> = cache.clone();
|
||||
let lease_port: Arc<dyn ProviderLeasePort> = coordinator.clone();
|
||||
let quota = Arc::new(crate::support::grok_quota_service(
|
||||
@@ -150,6 +153,7 @@ impl SelectorFixture {
|
||||
catalog_cache,
|
||||
quota,
|
||||
lease_port,
|
||||
cooldowns.clone(),
|
||||
Arc::clone(&feedback),
|
||||
);
|
||||
Self {
|
||||
@@ -157,6 +161,7 @@ impl SelectorFixture {
|
||||
cache,
|
||||
selector,
|
||||
coordinator,
|
||||
cooldowns,
|
||||
feedback,
|
||||
}
|
||||
}
|
||||
@@ -257,7 +262,7 @@ async fn required_account_overrides_smart_selection_without_fallback() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_feedback_marks_only_selected_account_invalid() {
|
||||
async fn unauthorized_feedback_records_runtime_cooldown_without_persisting_account_state() {
|
||||
let fixture = SelectorFixture::new(&["feedback-a", "feedback-b"]).await;
|
||||
let session = fixture
|
||||
.selector
|
||||
@@ -275,7 +280,7 @@ async fn unauthorized_feedback_marks_only_selected_account_invalid() {
|
||||
.account(&selected)
|
||||
.expect("selected")
|
||||
.availability(),
|
||||
AccountAvailability::Invalid
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
let other = [account_id("feedback-a"), account_id("feedback-b")]
|
||||
.into_iter()
|
||||
@@ -285,16 +290,78 @@ async fn unauthorized_feedback_marks_only_selected_account_invalid() {
|
||||
fixture.store.account(&other).expect("other").availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.cooldowns
|
||||
.cooldown(&selected)
|
||||
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_feedback_applies_bounded_cooldown() {
|
||||
let fixture = SelectorFixture::new(&["rate-limit"]).await;
|
||||
async fn cooldown_for_superseded_credential_does_not_block_rotated_account() {
|
||||
let fixture = SelectorFixture::new(&["revision-fence"]).await;
|
||||
let id = account_id("revision-fence");
|
||||
let session = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("session");
|
||||
fixture
|
||||
.selector
|
||||
.record_failure(
|
||||
&session,
|
||||
GrokCredentialFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(60)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let current = fixture
|
||||
.store
|
||||
.load_credential(&id, CredentialRevision::new(1).expect("revision"))
|
||||
.await
|
||||
.expect("current credential");
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_rotation(&RotateManagedGrokCredential {
|
||||
current,
|
||||
secret: provider_xai::GrokOAuthSecret {
|
||||
access_token: provider_xai::SecretValue::new("new-access"),
|
||||
refresh_token: provider_xai::SecretValue::new("new-refresh"),
|
||||
id_token: None,
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: crate::support::profile("subject-revision-fence"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
assert!(matches!(
|
||||
fixture
|
||||
.store
|
||||
.compare_and_swap_credential(prepared.credential)
|
||||
.await
|
||||
.expect("persist rotation"),
|
||||
CredentialCasOutcome::Updated(revision) if revision.get() == 2
|
||||
));
|
||||
|
||||
let next = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("replacement credential should not inherit cooldown");
|
||||
assert_eq!(next.account_id(), &id);
|
||||
assert!(fixture.cooldowns.cooldown(&id).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_feedback_records_runtime_cooldown_without_persisting_account_state() {
|
||||
let fixture = SelectorFixture::new(&["rate-limit", "available"]).await;
|
||||
let session = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("session");
|
||||
let selected = session.account_id().clone();
|
||||
fixture
|
||||
.selector
|
||||
.record_failure(
|
||||
@@ -304,20 +371,24 @@ async fn rate_limit_feedback_applies_bounded_cooldown() {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let account = fixture
|
||||
.store
|
||||
.account(session.account_id())
|
||||
.expect("account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Cooldown);
|
||||
let account = fixture.store.account(&selected).expect("account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
assert!(
|
||||
account
|
||||
.cooldown_until()
|
||||
.is_some_and(|until| until > SystemTime::now())
|
||||
fixture
|
||||
.cooldowns
|
||||
.cooldown(&selected)
|
||||
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
|
||||
);
|
||||
let next = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("runtime cooldown should leave the other account available");
|
||||
assert_ne!(next.account_id(), &selected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_stream_feedback_applies_account_cooldown() {
|
||||
async fn interrupted_stream_feedback_records_runtime_cooldown_without_persisting_account_state() {
|
||||
let fixture = SelectorFixture::new(&["stream-interrupted"]).await;
|
||||
let session = fixture
|
||||
.selector
|
||||
@@ -333,12 +404,20 @@ async fn interrupted_stream_feedback_applies_account_cooldown() {
|
||||
.store
|
||||
.account(session.account_id())
|
||||
.expect("account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Cooldown);
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
assert!(
|
||||
account
|
||||
.cooldown_until()
|
||||
.is_some_and(|until| until > SystemTime::now())
|
||||
fixture
|
||||
.cooldowns
|
||||
.cooldown(session.account_id())
|
||||
.is_some_and(|cooldown| cooldown.until() > SystemTime::now())
|
||||
);
|
||||
assert!(matches!(
|
||||
fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await,
|
||||
Err(GrokSessionSelectorError::NoEligibleSession)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -31,12 +31,14 @@ use gateway_core::routing::{
|
||||
use provider_xai::{
|
||||
GrokBuildProvider, GrokCredentialCatalogCache, GrokCredentialFailure,
|
||||
GrokCredentialFeedbackFuture, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
|
||||
GrokCredentialRepository, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
|
||||
GrokCredentialRepository, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
|
||||
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
|
||||
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
|
||||
GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportFuture,
|
||||
GrokModelCatalogTransportResponse, GrokSessionBinding, GrokSessionSelection,
|
||||
GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture, SecretValue,
|
||||
SelectedGrokSession,
|
||||
GrokInferenceTransportMetrics, GrokModelCatalogRequest, GrokModelCatalogTransport,
|
||||
GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
|
||||
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokSessionBinding,
|
||||
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
|
||||
SecretValue, SelectedGrokSession,
|
||||
};
|
||||
use serde_json::{Map, json};
|
||||
|
||||
@@ -47,6 +49,17 @@ use crate::support::{
|
||||
const MODEL: &str = "grok-4.5";
|
||||
const CATALOG_FIXTURE: &[u8] =
|
||||
include_bytes!("../transport/catalog/fixtures/official_grok_models_snapshot.json");
|
||||
|
||||
fn observed_transport_metrics() -> GrokInferenceTransportMetrics {
|
||||
GrokInferenceTransportMetrics::default()
|
||||
.with_headers_ms(42)
|
||||
.with_client_cache_status(GrokInferenceClientCacheStatus::Miss)
|
||||
.with_dns(GrokInferenceDnsObservation::new(
|
||||
GrokInferenceDnsSource::System,
|
||||
7,
|
||||
))
|
||||
}
|
||||
|
||||
const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
@@ -245,6 +258,7 @@ impl GrokSessionSelector for StubSelector {
|
||||
|
||||
enum InferenceMode {
|
||||
Success,
|
||||
SuccessWithMetrics(GrokInferenceTransportMetrics),
|
||||
SuccessBody(Vec<u8>),
|
||||
Error(GrokInferenceTransportError),
|
||||
StreamError(GrokInferenceTransportError),
|
||||
@@ -265,6 +279,14 @@ impl StubInferenceTransport {
|
||||
})
|
||||
}
|
||||
|
||||
fn success_with_metrics(metrics: GrokInferenceTransportMetrics) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
modes: Mutex::new(VecDeque::from([InferenceMode::SuccessWithMetrics(metrics)])),
|
||||
})
|
||||
}
|
||||
|
||||
fn error(error: GrokInferenceTransportError) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
@@ -308,6 +330,13 @@ impl GrokInferenceTransport for StubInferenceTransport {
|
||||
200,
|
||||
None,
|
||||
)),
|
||||
InferenceMode::SuccessWithMetrics(metrics) => Ok(GrokInferenceResponse::new(
|
||||
Box::pin(stream::iter([Ok(SUCCESS_SSE.to_vec())])),
|
||||
UpstreamHttpVersion::Http2,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.with_transport_metrics(metrics)),
|
||||
InferenceMode::SuccessBody(body) => Ok(GrokInferenceResponse::new(
|
||||
Box::pin(stream::iter([Ok(body)])),
|
||||
UpstreamHttpVersion::Http2,
|
||||
@@ -352,6 +381,18 @@ impl GrokModelCatalogTransport for CatalogWithoutToolMetadataTransport {
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableCatalogTransport;
|
||||
|
||||
impl GrokModelCatalogTransport for UnavailableCatalogTransport {
|
||||
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
|
||||
Box::pin(async {
|
||||
Err(GrokModelCatalogTransportError::new(
|
||||
GrokModelCatalogTransportErrorKind::Unavailable,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct StubRecovery {
|
||||
calls: AtomicUsize,
|
||||
outcome: GrokCredentialRecoveryOutcome,
|
||||
@@ -506,6 +547,19 @@ fn operation_with_reasoning_effort(effort: &str) -> Operation {
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
}
|
||||
|
||||
fn operation_with_invalid_tools() -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
("tools".to_owned(), json!({"not": "an array"})),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload");
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
}
|
||||
|
||||
fn compaction_operation() -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
@@ -693,7 +747,7 @@ async fn execute_forwards_required_account_to_selector() {
|
||||
#[tokio::test]
|
||||
async fn execute_returns_cold_stream_and_records_selected_account() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::success();
|
||||
let transport = StubInferenceTransport::success_with_metrics(observed_transport_metrics());
|
||||
let provider = provider(selector, transport.clone()).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
@@ -718,6 +772,23 @@ async fn execute_returns_cold_stream_and_records_selected_account() {
|
||||
assert_eq!(observation.transport().as_str(), "http_sse");
|
||||
assert_eq!(observation.http_version(), Some(UpstreamHttpVersion::Http2));
|
||||
assert_eq!(observation.status_code(), Some(200));
|
||||
assert_eq!(observation.timings().headers_ms, Some(42));
|
||||
let metadata: serde_json::Value = serde_json::from_str(
|
||||
observation
|
||||
.provider_metadata()
|
||||
.expect("xAI transport metadata")
|
||||
.as_json(),
|
||||
)
|
||||
.expect("valid metadata JSON");
|
||||
assert_eq!(
|
||||
metadata,
|
||||
json!({
|
||||
"clientCache": "miss",
|
||||
"dnsSource": "system",
|
||||
"dnsMs": 7,
|
||||
"upstreamHeadersMs": 42
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
@@ -727,6 +798,48 @@ async fn execute_returns_cold_stream_and_records_selected_account() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_error_observation_should_retain_available_metrics() {
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Unavailable,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(503)
|
||||
.with_transport_metrics(observed_transport_metrics()),
|
||||
);
|
||||
let provider = provider(StubSelector::success(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request("xai"),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
|
||||
let event = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("observation event")
|
||||
.expect("observation must succeed");
|
||||
let observation = event.response_observation().expect("transport observation");
|
||||
assert_eq!(observation.status_code(), Some(503));
|
||||
assert_eq!(observation.timings().headers_ms, Some(42));
|
||||
let metadata: serde_json::Value = serde_json::from_str(
|
||||
observation
|
||||
.provider_metadata()
|
||||
.expect("xAI transport metadata")
|
||||
.as_json(),
|
||||
)
|
||||
.expect("valid metadata JSON");
|
||||
assert_eq!(metadata["clientCache"], "miss");
|
||||
assert_eq!(metadata["dnsSource"], "system");
|
||||
assert_eq!(metadata["dnsMs"], 7);
|
||||
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_stream_should_emit_openai_wire_and_keep_only_accounting_canonical() {
|
||||
let summary = valid_compaction_summary("validated summary");
|
||||
@@ -1047,6 +1160,38 @@ async fn first_unauthorized_should_refresh_and_request_one_same_account_retry()
|
||||
assert!(selector.feedback.lock().expect("feedback").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_unauthorized_recovery_records_temporary_credential_feedback() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Unauthorized,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(401)
|
||||
.with_credential_recovery(),
|
||||
);
|
||||
let recovery = StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable);
|
||||
let provider = provider_with_recovery(selector.clone(), transport, recovery.clone()).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request("xai"),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("stream");
|
||||
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Unauthorized);
|
||||
assert!(!error.retries_same_account());
|
||||
assert_eq!(recovery.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::Unauthorized]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_http_429_marks_provider_error_replay_safe() {
|
||||
let error = mapped_transport_error(
|
||||
@@ -1628,6 +1773,37 @@ async fn provider_rejects_target_owned_by_other_provider() {
|
||||
assert_eq!(selector.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_should_expose_safe_request_normalization_field_before_selection() {
|
||||
let selector = StubSelector::success();
|
||||
let provider = provider(selector.clone(), StubInferenceTransport::success()).await;
|
||||
let error = match provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", operation_with_invalid_tools()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("invalid tools must fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
|
||||
assert_eq!(
|
||||
error.client_visible_upstream_error().map(|detail| (
|
||||
detail.message(),
|
||||
detail.code(),
|
||||
detail.error_type()
|
||||
)),
|
||||
Some((
|
||||
"Grok Build request field `tools` could not be normalized safely",
|
||||
Some("invalid_request_normalization"),
|
||||
Some("invalid_request_error"),
|
||||
)),
|
||||
);
|
||||
assert_eq!(selector.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_compiles_realtime_catalog_capabilities() {
|
||||
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
|
||||
@@ -1645,6 +1821,41 @@ async fn provider_compiles_realtime_catalog_capabilities() {
|
||||
))
|
||||
.is_some()
|
||||
);
|
||||
let presentation = capabilities[0]
|
||||
.presentation()
|
||||
.expect("Grok model presentation");
|
||||
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
|
||||
assert_eq!(
|
||||
presentation.supported_reasoning_efforts(),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
);
|
||||
assert_eq!(presentation.context_window_tokens(), Some(1_000_000));
|
||||
assert!(presentation.agent_tools());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_publishes_default_codex_profile_when_catalog_is_unavailable() {
|
||||
let provider = provider_with_catalog_transport(
|
||||
StubSelector::success(),
|
||||
StubInferenceTransport::success(),
|
||||
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
|
||||
Arc::new(UnavailableCatalogTransport),
|
||||
)
|
||||
.await;
|
||||
let capabilities = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("fallback capabilities");
|
||||
|
||||
assert_eq!(capabilities.len(), 1);
|
||||
assert_eq!(capabilities[0].upstream_model().as_str(), MODEL);
|
||||
let presentation = capabilities[0]
|
||||
.presentation()
|
||||
.expect("fallback Grok model presentation");
|
||||
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
|
||||
assert_eq!(presentation.context_window_tokens(), Some(500_000));
|
||||
assert!(presentation.agent_tools());
|
||||
assert!(presentation.parallel_tool_calls());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -15,9 +15,9 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderSessionAffinityKey,
|
||||
ProviderSessionAffinityPort, ProviderSessionExclusionPort, ProviderSessionExclusions,
|
||||
ProviderStoreError,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderSessionAffinityKey, ProviderSessionAffinityPort, ProviderSessionExclusionPort,
|
||||
ProviderSessionExclusions, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_xai::{
|
||||
@@ -550,6 +550,17 @@ pub struct TestSessionAffinity;
|
||||
|
||||
pub struct TestSessionExclusions;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryCooldownPort {
|
||||
cooldowns: Mutex<BTreeMap<ProviderAccountId, ProviderCooldown>>,
|
||||
}
|
||||
|
||||
impl MemoryCooldownPort {
|
||||
pub fn cooldown(&self, account_id: &ProviderAccountId) -> Option<ProviderCooldown> {
|
||||
lock(&self.cooldowns).get(account_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderSessionAffinityPort for TestSessionAffinity {
|
||||
fn load<'a>(
|
||||
&'a self,
|
||||
@@ -613,6 +624,50 @@ impl ProviderSessionExclusionPort for TestSessionExclusions {
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderCooldownPort for MemoryCooldownPort {
|
||||
fn put_if_later(
|
||||
&self,
|
||||
cooldown: ProviderCooldown,
|
||||
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let mut cooldowns = lock(&self.cooldowns);
|
||||
let should_write = cooldowns.get(cooldown.account_id()).is_none_or(|current| {
|
||||
current.credential_revision() < cooldown.credential_revision()
|
||||
|| (current.credential_revision() == cooldown.credential_revision()
|
||||
&& current.until() < cooldown.until())
|
||||
});
|
||||
if should_write {
|
||||
cooldowns.insert(cooldown.account_id().clone(), cooldown);
|
||||
}
|
||||
Ok(should_write)
|
||||
})
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
) -> futures::future::BoxFuture<'a, Result<Option<ProviderCooldown>, ProviderStoreError>> {
|
||||
Box::pin(async move { Ok(self.cooldown(account_id)) })
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
through_revision: CredentialRevision,
|
||||
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let mut cooldowns = lock(&self.cooldowns);
|
||||
let should_remove = cooldowns
|
||||
.get(account_id)
|
||||
.is_some_and(|current| current.credential_revision() <= through_revision);
|
||||
if should_remove {
|
||||
cooldowns.remove(account_id);
|
||||
}
|
||||
Ok(should_remove)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRuntimePolicyPort for StaticRuntimePolicy {
|
||||
fn load_refresh_policy(
|
||||
&self,
|
||||
|
||||
@@ -114,6 +114,42 @@ fn decoder_should_normalize_text_usage_and_completion() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_emit_each_complete_text_event_without_waiting_for_the_stream_end() {
|
||||
let mut decoder = GrokCanonicalDecoder::new("grok-4.5");
|
||||
let created = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_live\",\"model\":\"grok-4.5\"}}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("created event");
|
||||
let delta = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"live\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("text delta");
|
||||
|
||||
assert!(
|
||||
created
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.any(|event| event.event_type() == Some("response.created"))
|
||||
);
|
||||
assert!(delta.iter().any(|event| {
|
||||
event.wire_event().is_some_and(|wire| {
|
||||
wire.event_type() == Some("response.output_text.delta")
|
||||
&& wire.data().get("delta") == Some(&serde_json::json!("live"))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
let body = concat!(
|
||||
@@ -416,6 +452,72 @@ fn decoder_should_restore_custom_apply_patch_arguments_as_raw_input() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_buffer_only_custom_arguments_until_the_done_event() {
|
||||
let request = tool_request(serde_json::json!({
|
||||
"model": "client",
|
||||
"input": "render",
|
||||
"tools": [{"type": "custom", "name": "render"}]
|
||||
}));
|
||||
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.5", &request);
|
||||
decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_render\",\"model\":\"grok-4.5\"}}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("custom response created");
|
||||
let added = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"item_render\",\"type\":\"function_call\",\"call_id\":\"call_render\",\"name\":\"render\"}}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("custom item added");
|
||||
let first_delta = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"delta\":\"{\\\"input\\\":\\\"hel\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("first custom argument delta");
|
||||
let second_delta = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"delta\":\"lo\\\"}\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("second custom argument delta");
|
||||
let done = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"item_render\",\"call_id\":\"call_render\",\"output_index\":0,\"arguments\":\"{\\\"input\\\":\\\"hello\\\"}\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("custom argument done");
|
||||
|
||||
assert!(wire_events(&added).iter().any(|event| {
|
||||
event.event_type() == Some("response.output_item.added")
|
||||
&& event.data().pointer("/item/type") == Some(&serde_json::json!("custom_tool_call"))
|
||||
}));
|
||||
assert!(first_delta.is_empty());
|
||||
assert!(second_delta.is_empty());
|
||||
assert!(wire_events(&done).iter().any(|event| {
|
||||
event.event_type() == Some("response.custom_tool_call_input.done")
|
||||
&& event.data().get("input") == Some(&serde_json::json!("hello"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_coalesce_reasoning_item_part_and_summary_index() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -318,27 +419,28 @@ async fn inference_transport_should_classify_reasoning_decode_rejections() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_reject_success_with_non_sse_content_type() {
|
||||
async fn inference_transport_should_not_reject_sse_bytes_only_because_content_type_is_wrong() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
|
||||
"application/json",
|
||||
))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
let chunks = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("successful non-SSE response must fail closed");
|
||||
.expect("body syntax, rather than response metadata, determines the stream")
|
||||
.into_body()
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
(error.kind(), error.send_state()),
|
||||
(
|
||||
GrokInferenceTransportErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -534,6 +636,24 @@ async fn execute_inference(transport: &ReqwestGrokInferenceTransport, origin: &U
|
||||
struct CountingEndpointPolicy {
|
||||
inner: Arc<dyn GrokEndpointPolicy>,
|
||||
inference_client_builds: Arc<AtomicUsize>,
|
||||
concurrency: Option<Arc<BuildConcurrency>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BuildConcurrency {
|
||||
delay: Duration,
|
||||
active: AtomicUsize,
|
||||
max_active: AtomicUsize,
|
||||
}
|
||||
|
||||
impl BuildConcurrency {
|
||||
fn new(delay: Duration) -> Self {
|
||||
Self {
|
||||
delay,
|
||||
active: AtomicUsize::new(0),
|
||||
max_active: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GrokEndpointPolicy for CountingEndpointPolicy {
|
||||
@@ -549,7 +669,15 @@ impl GrokEndpointPolicy for CountingEndpointPolicy {
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Client, GrokReqwestTransportBuildError> {
|
||||
self.inference_client_builds.fetch_add(1, Ordering::SeqCst);
|
||||
self.inner.build_inference_client(timeout)
|
||||
let Some(concurrency) = &self.concurrency else {
|
||||
return self.inner.build_inference_client(timeout);
|
||||
};
|
||||
let active = concurrency.active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
concurrency.max_active.fetch_max(active, Ordering::SeqCst);
|
||||
std::thread::sleep(concurrency.delay);
|
||||
let result = self.inner.build_inference_client(timeout);
|
||||
concurrency.active.fetch_sub(1, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_oauth(&self, url: &Url) -> bool {
|
||||
|
||||
@@ -273,6 +273,104 @@ fn explicit_session_should_merge_client_tools_into_the_native_cache_route() {
|
||||
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_parameters_should_remove_only_nullable_object_roots() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "hello",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "automation_update",
|
||||
"parameters": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}]
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
}
|
||||
}]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("nullable object root");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/parameters/type"),
|
||||
Some(&json!("object"))
|
||||
);
|
||||
assert_eq!(body.pointer("/tools/0/parameters/anyOf"), None);
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/parameters/properties/title/anyOf"),
|
||||
Some(&json!([{"type": "string"}, {"type": "null"}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_parameters_should_keep_local_object_refs_after_removing_null() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "hello",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"$defs": {
|
||||
"Args": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"anyOf": [{"$ref": "#/$defs/Args"}, {"type": "null"}]
|
||||
}
|
||||
}]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("nullable local object ref");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/parameters/type"),
|
||||
Some(&json!("object"))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/parameters/anyOf"),
|
||||
Some(&json!([{"$ref": "#/$defs/Args"}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_parameters_should_report_the_invalid_nullable_root_field() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "hello",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "invalid",
|
||||
"parameters": {
|
||||
"anyOf": [{"type": "object"}, {"type": "string"}, {"type": "null"}]
|
||||
}
|
||||
}]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect_err("nullable non-object root"),
|
||||
GrokRequestEncodeError::InvalidRequestField {
|
||||
field: "tools[].parameters"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_session_should_enable_cache_after_codex_additional_tools_normalization() {
|
||||
let request = raw_request(json!({
|
||||
@@ -721,7 +819,7 @@ fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
|
||||
assert_eq!(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect_err("invalid compaction plaintext"),
|
||||
GrokRequestEncodeError::InvalidRequestNormalization
|
||||
GrokRequestEncodeError::InvalidRequestField { field: "input" }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -765,27 +863,42 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_ambiguous_tool_contracts_should_fail_before_upstream_io() {
|
||||
for body in [
|
||||
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
|
||||
json!({"model": "client", "input": "x", "tools": [
|
||||
{"type": "tool_search", "execution": "client"},
|
||||
{"type": "tool_search", "execution": "server"}
|
||||
]}),
|
||||
json!({"model": "client", "input": "x", "tools": [
|
||||
{"type": "shell"}, {"type": "local_shell"}
|
||||
]}),
|
||||
json!({"model": "client", "input": [{
|
||||
"type": "apply_patch_call",
|
||||
"call_id": "patch_1",
|
||||
"operation": {"type": "update_file", "path": "a.txt"}
|
||||
}]}),
|
||||
json!({"model": "client", "input": [{"type": "compaction_trigger"}]}),
|
||||
for (body, field) in [
|
||||
(
|
||||
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
|
||||
"tools",
|
||||
),
|
||||
(
|
||||
json!({"model": "client", "input": "x", "tools": [
|
||||
{"type": "tool_search", "execution": "client"},
|
||||
{"type": "tool_search", "execution": "server"}
|
||||
]}),
|
||||
"tools",
|
||||
),
|
||||
(
|
||||
json!({"model": "client", "input": "x", "tools": [
|
||||
{"type": "shell"}, {"type": "local_shell"}
|
||||
]}),
|
||||
"tools",
|
||||
),
|
||||
(
|
||||
json!({"model": "client", "input": [{
|
||||
"type": "apply_patch_call",
|
||||
"call_id": "patch_1",
|
||||
"operation": {"type": "update_file", "path": "a.txt"}
|
||||
}]}),
|
||||
"input",
|
||||
),
|
||||
(
|
||||
json!({"model": "client", "input": [{"type": "compaction_trigger"}]}),
|
||||
"input",
|
||||
),
|
||||
] {
|
||||
let request = raw_request(body);
|
||||
assert_eq!(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect_err("invalid tool contract"),
|
||||
GrokRequestEncodeError::InvalidRequestNormalization
|
||||
GrokRequestEncodeError::InvalidRequestField { field }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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" />
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
|
||||
<header class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
{{ title }}
|
||||
@@ -20,7 +20,10 @@ defineProps<{
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="mt-0.5 flex shrink-0 items-center gap-2">
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="mt-0.5 flex shrink-0 items-center gap-2 max-sm:w-full max-sm:justify-end"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { Menu } from '@lucide/vue'
|
||||
import {
|
||||
useDraggable,
|
||||
useElementSize,
|
||||
useScreenSafeArea,
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
interface StoredPosition {
|
||||
initialized: boolean
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const edgeGap = 12
|
||||
const defaultButtonSize = 44
|
||||
const dragClickThreshold = 6
|
||||
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
|
||||
|
||||
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
|
||||
initialWidth: 390,
|
||||
initialHeight: 760,
|
||||
type: 'visual',
|
||||
})
|
||||
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
|
||||
width: defaultButtonSize,
|
||||
height: defaultButtonSize,
|
||||
})
|
||||
const safeArea = useScreenSafeArea()
|
||||
const storedPosition = useStorage<StoredPosition>(
|
||||
storageKey,
|
||||
{
|
||||
initialized: false,
|
||||
side: 'left',
|
||||
x: edgeGap,
|
||||
y: 136,
|
||||
},
|
||||
undefined,
|
||||
{ mergeDefaults: true, shallow: true },
|
||||
)
|
||||
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const ignoreNextClick = shallowRef(false)
|
||||
const positionReady = shallowRef(false)
|
||||
let ignoreClickTimer: number | undefined
|
||||
|
||||
const bounds = computed(() => {
|
||||
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
|
||||
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
|
||||
const windowWidth = positiveNumber(viewportWidth.value, 390)
|
||||
const windowHeight = positiveNumber(viewportHeight.value, 760)
|
||||
const minX = parseCssPixel(safeArea.left.value) + edgeGap
|
||||
const maxX = Math.max(
|
||||
minX,
|
||||
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
|
||||
)
|
||||
const minY = parseCssPixel(safeArea.top.value) + edgeGap
|
||||
const maxY = Math.max(
|
||||
minY,
|
||||
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
|
||||
)
|
||||
|
||||
return { minX, maxX, minY, maxY }
|
||||
})
|
||||
|
||||
const { x, y, isDragging } = useDraggable(toggleEl, {
|
||||
initialValue: {
|
||||
x: storedPosition.value.x,
|
||||
y: storedPosition.value.y,
|
||||
},
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
onEnd(position) {
|
||||
applyPosition(snapPosition(position.x, position.y))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleStyle = computed<CSSProperties>(() => ({
|
||||
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
|
||||
touchAction: 'none',
|
||||
}))
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
pointerStart.value = { x: event.clientX, y: event.clientY }
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement)
|
||||
target.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement)
|
||||
target.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
const moved = Math.hypot(
|
||||
event.clientX - pointerStart.value.x,
|
||||
event.clientY - pointerStart.value.y,
|
||||
)
|
||||
ignoreNextClick.value = true
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
ignoreClickTimer = window.setTimeout(() => {
|
||||
ignoreNextClick.value = false
|
||||
}, 320)
|
||||
|
||||
if (moved <= dragClickThreshold) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isDragging.value || ignoreNextClick.value) {
|
||||
ignoreNextClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function defaultPosition() {
|
||||
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
|
||||
return snapPosition(bounds.value.minX, targetY)
|
||||
}
|
||||
|
||||
function restorePosition() {
|
||||
if (!storedPosition.value.initialized) {
|
||||
applyPosition(defaultPosition())
|
||||
return
|
||||
}
|
||||
|
||||
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
|
||||
applyPosition({
|
||||
initialized: true,
|
||||
side: storedPosition.value.side,
|
||||
x: sideX,
|
||||
y: storedPosition.value.y,
|
||||
})
|
||||
}
|
||||
|
||||
function snapPosition(rawX: number, rawY: number) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
side,
|
||||
x: side === 'left' ? minX : maxX,
|
||||
y: clamp(rawY, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
}
|
||||
|
||||
function applyPosition(position: StoredPosition) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const next = {
|
||||
initialized: true,
|
||||
side: position.side,
|
||||
x: clamp(position.x, minX, maxX),
|
||||
y: clamp(position.y, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
|
||||
x.value = next.x
|
||||
y.value = next.y
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function parseCssPixel(value: string) {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
safeArea.update()
|
||||
restorePosition()
|
||||
positionReady.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
bounds,
|
||||
() => {
|
||||
if (!positionReady.value)
|
||||
return
|
||||
restorePosition()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="toggleEl"
|
||||
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
|
||||
:class="positionReady ? 'opacity-100' : 'opacity-0'"
|
||||
:style="toggleStyle"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
|
||||
:class="
|
||||
isDragging
|
||||
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
|
||||
: 'cursor-grab'
|
||||
"
|
||||
aria-label="打开侧边栏"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-40 -mx-4 -mt-4 mb-4 flex h-15 items-center bg-(--cp-bg-page)/96 px-4 backdrop-blur-sm min-[961px]:hidden"
|
||||
>
|
||||
<BaseButton
|
||||
icon-only
|
||||
size="md"
|
||||
variant="default"
|
||||
label="打开侧边栏"
|
||||
class="shadow-[0_12px_28px_-20px_var(--cp-shadow-sticky)]"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppAboutModal from './components/AppAboutModal.vue'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import MobileSidebarToolbar from './components/MobileSidebarToolbar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
import SystemUpdateModal from './components/SystemUpdateModal.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
@@ -77,12 +77,12 @@ watch(
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
/>
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
ref="pageScrollbarRef"
|
||||
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
|
||||
>
|
||||
<MobileSidebarToolbar v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
|
||||
import type { AccountRow } from '../constants'
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Copy, Grid2X2, KeyRound, Upload } from '@lucide/vue'
|
||||
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
|
||||
|
||||
import { useClipboard, useFileDialog } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -207,7 +207,7 @@ async function copyText(value: string, successText: string) {
|
||||
:hide-footer="isChoosingProvider"
|
||||
>
|
||||
<template #icon>
|
||||
<Grid2X2 v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<LayoutGrid v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
</template>
|
||||
|
||||
@@ -33,8 +33,8 @@ const overviewItems = computed(() => [
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
label: '需处理',
|
||||
value: formatCount(props.summary.attention),
|
||||
label: '不可用账号',
|
||||
value: formatCount(props.summary.unavailable),
|
||||
caption: '过期 / 禁用 / 封禁',
|
||||
tone: 'danger',
|
||||
icon: AlertTriangle,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Grid2X2 } from '@lucide/vue'
|
||||
import { LayoutGrid } from '@lucide/vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -16,6 +16,11 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const providers = [
|
||||
{
|
||||
value: 'batch' as const,
|
||||
label: '批量导入',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
value: 'openai' as const,
|
||||
label: 'OpenAI',
|
||||
@@ -26,11 +31,6 @@ const providers = [
|
||||
label: 'xAI',
|
||||
icon: Xai,
|
||||
},
|
||||
{
|
||||
value: 'batch' as const,
|
||||
label: '批量导入',
|
||||
icon: Grid2X2,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -273,7 +273,10 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
|
||||
if (event.type === 'error') {
|
||||
applyAccountStatus(event.accountStatus)
|
||||
connectionTestError.value = event.error || '测试连接失败'
|
||||
appendConnectionTestLog(connectionTestError.value, 'danger')
|
||||
const detail = event.providerErrorCode || event.providerErrorType
|
||||
? { code: event.providerErrorCode, type: event.providerErrorType }
|
||||
: undefined
|
||||
appendConnectionTestLog(connectionTestError.value, 'danger', detail)
|
||||
finishConnectionTest('error')
|
||||
clearConnectionTestRun()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useAccountOnboarding } from './useAccountOnboarding'
|
||||
|
||||
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
|
||||
const attentionAccountStatuses = new Set(['expired', 'disabled', 'banned'])
|
||||
const accountStatusSortRank: Record<string, number> = {
|
||||
active: 0,
|
||||
quota_exhausted: 1,
|
||||
@@ -250,15 +249,19 @@ export function useAccountMutations(options: {
|
||||
+ Number(status === 'quota_exhausted')
|
||||
- Number(current.status === 'quota_exhausted'),
|
||||
),
|
||||
attention: Math.max(
|
||||
unavailable: Math.max(
|
||||
0,
|
||||
options.accountSummary.value.attention
|
||||
+ Number(attentionAccountStatuses.has(status))
|
||||
- Number(attentionAccountStatuses.has(current.status)),
|
||||
options.accountSummary.value.unavailable
|
||||
+ Number(isUnavailableAccountStatus(status))
|
||||
- Number(isUnavailableAccountStatus(current.status)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function isUnavailableAccountStatus(status: string) {
|
||||
return status === 'expired' || status === 'disabled' || status === 'banned'
|
||||
}
|
||||
|
||||
function sortAccountsByStatus(rows: AccountRow[]) {
|
||||
if (options.sort.value?.key !== 'status')
|
||||
return rows
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useAccountsQuery() {
|
||||
total: 0,
|
||||
active: 0,
|
||||
quotaExhausted: 0,
|
||||
attention: 0,
|
||||
unavailable: 0,
|
||||
})
|
||||
|
||||
const query = usePagedQuery({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ const emptyCards: DashboardSummary['cards'] = {
|
||||
credentials: {
|
||||
total: '0',
|
||||
totalValue: 0,
|
||||
enabled: '0',
|
||||
enabledValue: 0,
|
||||
available: '0',
|
||||
availableValue: 0,
|
||||
unavailable: '0',
|
||||
unavailableValue: 0,
|
||||
},
|
||||
@@ -147,9 +147,9 @@ function metricCards(
|
||||
tone: 'normal',
|
||||
details: [
|
||||
{
|
||||
label: '启用',
|
||||
value: credentials.enabled,
|
||||
tone: credentials.enabledValue > 0 ? 'success' : 'normal',
|
||||
label: '可用',
|
||||
value: credentials.available,
|
||||
tone: credentials.availableValue > 0 ? 'success' : 'normal',
|
||||
},
|
||||
{
|
||||
label: '不可用',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, Cat, CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
|
||||
import { ArrowRight, CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@@ -58,7 +58,12 @@ function togglePasswordVisible(): void {
|
||||
<header class="flex min-w-0 items-center justify-between gap-4.5 max-[560px]:gap-3.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<BaseMotionIcon aria-hidden="true" variant="brand" class="login-logo">
|
||||
<Cat :size="20" :stroke-width="2.1" />
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
alt=""
|
||||
class="block size-8 select-none"
|
||||
draggable="false"
|
||||
>
|
||||
</BaseMotionIcon>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<strong
|
||||
|
||||
@@ -93,7 +93,7 @@ async function handleSubmit(): Promise<void> {
|
||||
--login-form-bg-c: color-mix(in srgb, var(--cp-bg-muted) 78%, var(--cp-info-bg));
|
||||
--login-form-shadow: color-mix(in srgb, var(--cp-text-primary) 20%, transparent);
|
||||
--login-form-line: color-mix(in srgb, var(--cp-info-border) 54%, transparent);
|
||||
--login-logo-bg: color-mix(in srgb, var(--cp-info-bg) 74%, var(--cp-bg-surface));
|
||||
--login-logo-bg: var(--cp-brand-mark-bg);
|
||||
--login-logo-text: var(--cp-accent-primary);
|
||||
--login-title: var(--cp-text-primary);
|
||||
--login-brand-title: var(--cp-text-strong);
|
||||
@@ -159,7 +159,7 @@ async function handleSubmit(): Promise<void> {
|
||||
--login-form-bg-c: color-mix(in srgb, var(--cp-bg-page) 90%, transparent);
|
||||
--login-form-shadow: color-mix(in srgb, var(--cp-bg-dark) 70%, transparent);
|
||||
--login-form-line: color-mix(in srgb, var(--cp-info-text) 66%, transparent);
|
||||
--login-logo-bg: color-mix(in srgb, var(--cp-bg-surface) 84%, transparent);
|
||||
--login-logo-bg: var(--cp-brand-mark-bg);
|
||||
--login-logo-text: var(--cp-info-text);
|
||||
--login-title: var(--cp-text-strong);
|
||||
--login-brand-title: var(--cp-white);
|
||||
|
||||
@@ -57,7 +57,7 @@ function latencyDisplay(value: unknown) {
|
||||
<template v-if="record">
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<UsageStatusCodeBadge :status-code="record.statusCode" />
|
||||
<UsageStatusCodeBadge :status-code="record.upstreamStatusCode" />
|
||||
<span class="text-[12px] font-bold text-(--cp-text-secondary)">
|
||||
客户端 {{ display(record.clientStatusCode) }}
|
||||
</span>
|
||||
|
||||
@@ -88,9 +88,9 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #statusCode="{ row }">
|
||||
<template #upstreamStatusCode="{ row }">
|
||||
<UsageStatusCodeBadge
|
||||
:status-code="typeof row.statusCode === 'number' ? row.statusCode : null"
|
||||
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
|
||||
/>
|
||||
</template>
|
||||
<template #failureClass="{ row }">
|
||||
|
||||
@@ -9,6 +9,10 @@ const props = defineProps<{
|
||||
summary: Awaited<ReturnType<typeof getUsageRecordSummary>>
|
||||
}>()
|
||||
|
||||
function averageLatencyDisplay(value: string) {
|
||||
return !value || value === '—' || value === '-' ? '0 ms' : value
|
||||
}
|
||||
|
||||
const items = computed(() => [
|
||||
{
|
||||
key: 'requests',
|
||||
@@ -38,7 +42,7 @@ const items = computed(() => [
|
||||
key: 'latency',
|
||||
label: '平均耗时',
|
||||
icon: Timer,
|
||||
value: props.summary.averageLatencyMs,
|
||||
value: averageLatencyDisplay(props.summary.averageLatencyMs),
|
||||
detail: '成功请求平均值',
|
||||
tone: 'text-(--cp-normal-text) bg-(--cp-normal-bg)',
|
||||
},
|
||||
|
||||
@@ -247,7 +247,7 @@ function emptySummary() {
|
||||
cachedTokens: '0',
|
||||
cacheWriteTokens: '0',
|
||||
totalTokens: '0',
|
||||
averageLatencyMs: '—',
|
||||
averageLatencyMs: '0 ms',
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const opsErrorColumns = [
|
||||
cellClass:
|
||||
'whitespace-nowrap font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
{ key: 'statusCode', label: '状态码', width: '96px', align: 'center' as const },
|
||||
{ key: 'upstreamStatusCode', label: '上游状态', width: '96px', align: 'center' as const },
|
||||
{
|
||||
key: 'failureClass',
|
||||
label: '失败分类',
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.2"
|
||||
version: "3.0.9"
|
||||
|
||||
Reference in New Issue
Block a user