mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 00:06:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3110b71512 | ||
|
|
eac1b2c39e | ||
|
|
337bb80230 | ||
|
|
82f2a0da06 |
Generated
+1
@@ -1215,6 +1215,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
"tower-http 0.7.0",
|
||||
"tracing",
|
||||
|
||||
@@ -31,7 +31,9 @@ uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread"] }
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1055,6 +1055,86 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let raw_created = Bytes::from_static(
|
||||
b"event: response.created\r\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_atomic_api\",\"model\":\"public-model\",\"status\":\"in_progress\"}}\r\n\r\n",
|
||||
);
|
||||
let raw_failure = Bytes::from_static(
|
||||
b"event: response.failed\r\ndata: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_atomic_api\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"atomic upstream marker\"}}}\r\n\r\n",
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_atomic_api",
|
||||
"model": "public-model",
|
||||
"status": "in_progress"
|
||||
}
|
||||
}),
|
||||
raw_created.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let failed_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_atomic_api",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "rate_limit_exceeded",
|
||||
"message": "atomic upstream marker"
|
||||
}
|
||||
}
|
||||
}),
|
||||
raw_failure.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream failure wire"),
|
||||
);
|
||||
let batch = CoordinatedEvent::try_batch(
|
||||
vec![started_wire, failed_wire],
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)
|
||||
.expect("atomic failure delivery");
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(batch),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
|
||||
assert_eq!(body.matches("atomic upstream marker").count(), 1);
|
||||
assert_eq!(body.matches("event: response.failed").count(), 1);
|
||||
assert!(body.contains(std::str::from_utf8(&raw_created).expect("created frame")));
|
||||
assert!(body.contains(std::str::from_utf8(&raw_failure).expect("failure frame")));
|
||||
assert!(!body.contains("upstream capacity is temporarily unavailable"));
|
||||
assert!(body.ends_with("data: [DONE]\n\n"));
|
||||
assert_eq!(trace.snapshot(), vec!["next_event", "commit", "next_error"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_second_commit_request_should_fail_and_finalize_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use futures::{SinkExt, StreamExt, future::BoxFuture};
|
||||
use gateway_api::openai::responses::ResponseCreateFrameError;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
|
||||
};
|
||||
use gateway_core::operation::Operation;
|
||||
use serde_json::json;
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as ClientMessage;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::decode_response_create;
|
||||
use crate::openai::{api_router, models::ModelsExecution};
|
||||
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
|
||||
|
||||
#[test]
|
||||
fn response_create_should_default_to_the_websocket_streaming_contract() {
|
||||
@@ -148,11 +168,233 @@ fn response_create_should_reject_non_boolean_stream_without_disclosing_body_valu
|
||||
assert!(!rendered.contains(opaque_stream_value));
|
||||
}
|
||||
|
||||
// `serve_responses_websocket`/`forward_execution` 只有拿到 `hyper::upgrade::OnUpgrade`
|
||||
// 的真实 HTTP/1.1 升级连接才会运行;本 crate 的依赖图按 axum `ws` feature 编译
|
||||
// hyper(无 `http1`),测试进程内不存在能完成升级的服务端,`hyper::upgrade` 也不
|
||||
// 提供公开构造器。集成测试因此止步于 upgrade 边界:会话循环内的事件转发、错误帧
|
||||
// 与断连取消仍未被覆盖,需要 dev 依赖开启 `http1` 或 socket 泛化后才能落地。
|
||||
#[derive(Default)]
|
||||
struct AtomicFailureTrace {
|
||||
next_calls: AtomicUsize,
|
||||
committed: AtomicBool,
|
||||
finalized: AtomicBool,
|
||||
}
|
||||
|
||||
struct AtomicFailureSession {
|
||||
trace: Arc<AtomicFailureTrace>,
|
||||
}
|
||||
|
||||
impl ExecutionSession for AtomicFailureSession {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
match self.trace.next_calls.fetch_add(1, Ordering::AcqRel) {
|
||||
0 => Ok(Some(atomic_failure_batch())),
|
||||
1 => {
|
||||
self.trace.finalized.store(true, Ordering::Release);
|
||||
Err(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
|
||||
Box::pin(async { Err(EngineError::InvalidDeliveryState) })
|
||||
}
|
||||
|
||||
fn response_headers(&self) -> &[ProviderResponseHeader] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn commit_downstream(&mut self, _: Option<u16>) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async move {
|
||||
self.trace.committed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn is_finalized(&self) -> bool {
|
||||
self.trace.finalized.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn cancel(&self) {}
|
||||
|
||||
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
Box::pin(async move {
|
||||
self.trace.finalized.store(true, Ordering::Release);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct AtomicFailureExecution {
|
||||
client: AuthenticatedClient,
|
||||
trace: Arc<AtomicFailureTrace>,
|
||||
}
|
||||
|
||||
impl ExecutionService for AtomicFailureExecution {
|
||||
fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
|
||||
(plaintext == "sk_ws_atomic")
|
||||
.then(|| self.client.clone())
|
||||
.ok_or(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
vec![PublicModelId::new("model-a").expect("public model")]
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
model.as_str() == "model-a"
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
request: StartExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
Ok(StartedExecution {
|
||||
request_id: gateway_core::engine::ModelRequestId::new("req_ws_atomic")
|
||||
.expect("request id"),
|
||||
created_at: SystemTime::now(),
|
||||
stream: request.metadata.stream,
|
||||
session: Box::new(AtomicFailureSession {
|
||||
trace: Arc::clone(&self.trace),
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_failure_batch() -> CoordinatedEvent {
|
||||
let started = ProviderEvent::canonical_with_wire(
|
||||
vec![GatewayEvent::Started(ResponseMeta::new(
|
||||
"resp_ws_atomic",
|
||||
"model-a",
|
||||
))],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_ws_atomic",
|
||||
"model": "model-a",
|
||||
"status": "in_progress"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("created wire"),
|
||||
);
|
||||
let failed = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_ws_atomic",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "rate_limit_exceeded",
|
||||
"message": "websocket atomic upstream marker"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("failed wire"),
|
||||
);
|
||||
CoordinatedEvent::try_batch(
|
||||
vec![started, failed],
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)
|
||||
.expect("atomic WebSocket batch")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(AtomicFailureTrace::default());
|
||||
let execution = Arc::new(AtomicFailureExecution {
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
trace: Arc::clone(&trace),
|
||||
});
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind WebSocket test server");
|
||||
let address = listener.local_addr().expect("test server address");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve WebSocket test router");
|
||||
});
|
||||
let mut request = format!("ws://{address}/v1/responses")
|
||||
.into_client_request()
|
||||
.expect("WebSocket request");
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
"Bearer sk_ws_atomic".parse().expect("authorization"),
|
||||
);
|
||||
let (mut socket, response) = connect_async(request).await.expect("upgrade WebSocket");
|
||||
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
|
||||
socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({
|
||||
"type": "response.create",
|
||||
"model": "model-a",
|
||||
"input": "hello"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.create");
|
||||
|
||||
let mut messages = Vec::<Value>::new();
|
||||
loop {
|
||||
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.expect("WebSocket response timeout")
|
||||
.expect("WebSocket remains open")
|
||||
.expect("valid WebSocket frame");
|
||||
let ClientMessage::Text(text) = message else {
|
||||
continue;
|
||||
};
|
||||
let value: Value = serde_json::from_str(&text).expect("JSON WebSocket event");
|
||||
let failed = value.get("type").and_then(Value::as_str) == Some("response.failed");
|
||||
messages.push(value);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| message.get("type").and_then(Value::as_str) == Some("response.failed"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let failed = messages
|
||||
.iter()
|
||||
.find(|message| message.get("type").and_then(Value::as_str) == Some("response.failed"))
|
||||
.expect("upstream failed event");
|
||||
assert_eq!(
|
||||
failed
|
||||
.pointer("/response/error/message")
|
||||
.and_then(Value::as_str),
|
||||
Some("websocket atomic upstream marker")
|
||||
);
|
||||
assert!(trace.committed.load(Ordering::Acquire));
|
||||
assert_eq!(trace.next_calls.load(Ordering::Acquire), 2);
|
||||
assert!(trace.finalized.load(Ordering::Acquire));
|
||||
|
||||
socket.close(None).await.expect("close WebSocket");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
const TEST_WEBSOCKET_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
|
||||
@@ -165,7 +165,9 @@ where
|
||||
client_response_id: None,
|
||||
upstream_response_id: None,
|
||||
last_account_exhaustion: None,
|
||||
last_account_exhaustion_events: Vec::new(),
|
||||
provider_attempt_outcomes: Vec::new(),
|
||||
pending_terminal_failure: None,
|
||||
};
|
||||
|
||||
if session.cancellation.is_cancelled() {
|
||||
@@ -243,7 +245,16 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
client_response_id: Option<String>,
|
||||
upstream_response_id: Option<String>,
|
||||
last_account_exhaustion: Option<ProviderError>,
|
||||
/// 最近一次账号耗尽在重试时被丢弃的原始失败批次;只用于后续空选路终态。
|
||||
last_account_exhaustion_events: Vec<ProviderEvent>,
|
||||
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
|
||||
/// 原子失败批次已交给协议层、但尚待下游提交后收敛的原 Provider 错误。
|
||||
pending_terminal_failure: Option<PendingTerminalFailure>,
|
||||
}
|
||||
|
||||
struct PendingTerminalFailure {
|
||||
error: ProviderError,
|
||||
send_state: UpstreamSendState,
|
||||
}
|
||||
|
||||
impl<S: ?Sized> ResponseExecutionSession<S>
|
||||
@@ -259,6 +270,12 @@ where
|
||||
if self.delivery_pending {
|
||||
return Err(EngineError::DownstreamCommitRequired);
|
||||
}
|
||||
if let Some(pending) = self.pending_terminal_failure.take() {
|
||||
let engine_error = provider_engine_error(&pending.error);
|
||||
self.finish_provider_error_with_send_state(&pending.error, pending.send_state)
|
||||
.await?;
|
||||
return Err(engine_error);
|
||||
}
|
||||
if self.finalized {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -278,6 +295,21 @@ where
|
||||
)));
|
||||
}
|
||||
PullOutcome::AttemptDiscarded => {}
|
||||
PullOutcome::TerminalFailure {
|
||||
events,
|
||||
error,
|
||||
send_state,
|
||||
} => {
|
||||
let requirement = if self.downstream_committed_at.is_some() {
|
||||
CommitRequirement::AlreadyCommitted
|
||||
} else {
|
||||
self.delivery_pending = true;
|
||||
CommitRequirement::CommitBeforeDelivery
|
||||
};
|
||||
self.pending_terminal_failure =
|
||||
Some(PendingTerminalFailure { error, send_state });
|
||||
return CoordinatedEvent::try_batch(events, requirement).map(Some);
|
||||
}
|
||||
PullOutcome::End => {
|
||||
if self.downstream_committed_at.is_some() {
|
||||
self.finish_success().await?;
|
||||
@@ -306,6 +338,14 @@ where
|
||||
match self.pull().await? {
|
||||
PullOutcome::Event(event) => events.push(event),
|
||||
PullOutcome::AttemptDiscarded => events.clear(),
|
||||
PullOutcome::TerminalFailure {
|
||||
error, send_state, ..
|
||||
} => {
|
||||
let engine_error = provider_engine_error(&error);
|
||||
self.finish_provider_error_with_send_state(&error, send_state)
|
||||
.await?;
|
||||
return Err(engine_error);
|
||||
}
|
||||
PullOutcome::End => {
|
||||
if events.is_empty() {
|
||||
return Err(EngineError::InvalidDeliveryState);
|
||||
@@ -434,13 +474,20 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
self.delivery_pending = false;
|
||||
if let Some(pending) = self.pending_terminal_failure.take() {
|
||||
return self
|
||||
.finish_provider_error_with_send_state(&pending.error, pending.send_state)
|
||||
.await;
|
||||
}
|
||||
self.finish_interruption(EngineError::Cancelled).await
|
||||
}
|
||||
|
||||
async fn pull(&mut self) -> Result<PullOutcome, EngineError> {
|
||||
loop {
|
||||
if self.current.is_none() {
|
||||
self.prepare_attempt().await?;
|
||||
if self.current.is_none()
|
||||
&& let Some(outcome) = self.prepare_attempt().await?
|
||||
{
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let boundary = {
|
||||
@@ -470,10 +517,14 @@ where
|
||||
if let Some(observation) = event.take_observation()
|
||||
&& let Err(error) = self.observe_response(observation)
|
||||
{
|
||||
if self.handle_stream_error(error).await? {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
match self.handle_stream_error(error).await? {
|
||||
StreamErrorOutcome::AttemptDiscarded => {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
}
|
||||
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
|
||||
return Ok(terminal.into_pull_outcome());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut identity_error = None;
|
||||
for fact in event.canonical_facts() {
|
||||
@@ -483,10 +534,14 @@ where
|
||||
}
|
||||
}
|
||||
if let Some(error) = identity_error {
|
||||
if self.handle_stream_error(error).await? {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
match self.handle_stream_error(error).await? {
|
||||
StreamErrorOutcome::AttemptDiscarded => {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
}
|
||||
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
|
||||
return Ok(terminal.into_pull_outcome());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
self.observe_event(fact).await?;
|
||||
@@ -500,8 +555,13 @@ where
|
||||
return Ok(PullOutcome::Event(event));
|
||||
}
|
||||
PollBoundary::Item(Some(Err(error))) => {
|
||||
if self.handle_stream_error(error).await? {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
match self.handle_stream_error(error).await? {
|
||||
StreamErrorOutcome::AttemptDiscarded => {
|
||||
return Ok(PullOutcome::AttemptDiscarded);
|
||||
}
|
||||
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
|
||||
return Ok(terminal.into_pull_outcome());
|
||||
}
|
||||
}
|
||||
}
|
||||
PollBoundary::Item(None) => {
|
||||
@@ -513,7 +573,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_attempt(&mut self) -> Result<(), EngineError> {
|
||||
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
|
||||
if self.attempts >= self.plan.max_attempts().get() {
|
||||
return Err(EngineError::EmptyRoutingPlan);
|
||||
}
|
||||
@@ -615,6 +675,16 @@ where
|
||||
if error.kind() == ProviderErrorKind::NoEligibleAccount
|
||||
&& let Some(exhaustion) = self.last_account_exhaustion.clone()
|
||||
{
|
||||
let send_state = self.current_send_state();
|
||||
let mut events = std::mem::take(&mut self.last_account_exhaustion_events);
|
||||
if !events.is_empty() {
|
||||
self.observe_atomic_terminal_events(&mut events)?;
|
||||
return Ok(Some(PullOutcome::TerminalFailure {
|
||||
events,
|
||||
error: exhaustion,
|
||||
send_state,
|
||||
}));
|
||||
}
|
||||
let engine_error = provider_engine_error(&exhaustion);
|
||||
self.finish_provider_error(&exhaustion).await?;
|
||||
return Err(engine_error);
|
||||
@@ -739,10 +809,24 @@ where
|
||||
send_observed: false,
|
||||
response_observation: None,
|
||||
});
|
||||
Ok(())
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn observe_event(&mut self, event: &GatewayEvent) -> Result<(), EngineError> {
|
||||
self.observe_event_facts(event);
|
||||
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if !current.send_observed {
|
||||
self.engine
|
||||
.store()
|
||||
.mark_send_state(&self.request_id, UpstreamSendState::Sent)
|
||||
.await?;
|
||||
current.send_observed = true;
|
||||
self.send_state_watermark = UpstreamSendState::Sent;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observe_event_facts(&mut self, event: &GatewayEvent) {
|
||||
let elapsed = elapsed_ms(self.timing_started_at);
|
||||
observe_event_timing(&mut self.timings, event, elapsed);
|
||||
if let GatewayEvent::Usage(observed) = event {
|
||||
@@ -756,16 +840,6 @@ where
|
||||
if let GatewayEvent::ProviderCost(observed) = event {
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if !current.send_observed {
|
||||
self.engine
|
||||
.store()
|
||||
.mark_send_state(&self.request_id, UpstreamSendState::Sent)
|
||||
.await?;
|
||||
current.send_observed = true;
|
||||
self.send_state_watermark = UpstreamSendState::Sent;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn observe_wire_event(&mut self) -> Result<(), EngineError> {
|
||||
@@ -855,14 +929,22 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 返回 `true` 表示调用方必须丢弃本 attempt 已收集的未提交事件。
|
||||
async fn handle_stream_error(&mut self, error: ProviderError) -> Result<bool, EngineError> {
|
||||
/// 失败可重试时要求丢弃本 attempt;预算耗尽时可返回最后一批原始失败事件。
|
||||
async fn handle_stream_error(
|
||||
&mut self,
|
||||
mut error: ProviderError,
|
||||
) -> Result<StreamErrorOutcome, EngineError> {
|
||||
// 原始 wire 只能活在本次决策栈内;后续 clone、attempt 记录与终态对象均只
|
||||
// 接触已剥离的稳定错误字段。
|
||||
let mut atomic_client_events = error.take_atomic_client_events();
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if matches!(
|
||||
let account_exhausted = matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted
|
||||
) {
|
||||
);
|
||||
if account_exhausted {
|
||||
self.last_account_exhaustion = Some(error.clone());
|
||||
self.last_account_exhaustion_events.clear();
|
||||
}
|
||||
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
|
||||
// attempt_send_state 是本 attempt 自身的发送事实,驱动重试门;
|
||||
@@ -910,6 +992,9 @@ where
|
||||
let retryable = continuation_retry || same_account_retry || ordinary_retry;
|
||||
|
||||
if retryable {
|
||||
if account_exhausted {
|
||||
self.last_account_exhaustion_events = atomic_client_events;
|
||||
}
|
||||
if same_account_retry {
|
||||
if let Some(account) = current.metadata.provider_account_id() {
|
||||
self.credential_recovery_attempted_accounts
|
||||
@@ -946,16 +1031,42 @@ where
|
||||
})
|
||||
.await?;
|
||||
self.reset_uncommitted_observations();
|
||||
return Ok(true);
|
||||
return Ok(StreamErrorOutcome::AttemptDiscarded);
|
||||
}
|
||||
|
||||
let engine_error = provider_engine_error(&error);
|
||||
self.current = Some(current);
|
||||
if !atomic_client_events.is_empty() {
|
||||
self.observe_atomic_terminal_events(&mut atomic_client_events)?;
|
||||
return Ok(StreamErrorOutcome::TerminalFailure {
|
||||
events: atomic_client_events,
|
||||
error,
|
||||
send_state,
|
||||
});
|
||||
}
|
||||
self.finish_provider_error_with_send_state(&error, send_state)
|
||||
.await?;
|
||||
Err(engine_error)
|
||||
}
|
||||
|
||||
fn observe_atomic_terminal_events(
|
||||
&mut self,
|
||||
events: &mut [ProviderEvent],
|
||||
) -> Result<(), EngineError> {
|
||||
for event in events {
|
||||
if let Some(observation) = event.take_observation() {
|
||||
self.observe_response(observation)
|
||||
.map_err(EngineError::Provider)?;
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
self.observe_response_identity(fact)
|
||||
.map_err(EngineError::Provider)?;
|
||||
self.observe_event_facts(fact);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_continuation_retry(
|
||||
&mut self,
|
||||
current: &CurrentAttempt,
|
||||
@@ -1305,9 +1416,40 @@ where
|
||||
enum PullOutcome {
|
||||
Event(ProviderEvent),
|
||||
AttemptDiscarded,
|
||||
TerminalFailure {
|
||||
events: Vec<ProviderEvent>,
|
||||
error: ProviderError,
|
||||
send_state: UpstreamSendState,
|
||||
},
|
||||
End,
|
||||
}
|
||||
|
||||
enum StreamErrorOutcome {
|
||||
AttemptDiscarded,
|
||||
TerminalFailure {
|
||||
events: Vec<ProviderEvent>,
|
||||
error: ProviderError,
|
||||
send_state: UpstreamSendState,
|
||||
},
|
||||
}
|
||||
|
||||
impl StreamErrorOutcome {
|
||||
fn into_pull_outcome(self) -> PullOutcome {
|
||||
match self {
|
||||
Self::AttemptDiscarded => PullOutcome::AttemptDiscarded,
|
||||
Self::TerminalFailure {
|
||||
events,
|
||||
error,
|
||||
send_state,
|
||||
} => PullOutcome::TerminalFailure {
|
||||
events,
|
||||
error,
|
||||
send_state,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PollBoundary {
|
||||
Item(Option<Result<ProviderEvent, ProviderError>>),
|
||||
Cancelled,
|
||||
|
||||
@@ -525,7 +525,8 @@ impl DefaultExecutionService {
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测失败先落库再转成客户端错误;转换会丢掉上游状态码与 provider code。
|
||||
/// 探测失败先记录完整分类事实,再仅把 Provider 显式标记为客户端可见的
|
||||
/// 结构化 message/code/type 带入协议错误。
|
||||
async fn observe_probe_failure(
|
||||
&self,
|
||||
observed: &ProbeObservation,
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::engine::UpstreamSendState;
|
||||
use crate::event::ProviderEvent;
|
||||
|
||||
/// 应用层标识不满足约束。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
@@ -266,9 +267,13 @@ impl fmt::Debug for ClientVisibleUpstreamError {
|
||||
|
||||
/// 单次 Provider 调用的稳定错误。
|
||||
///
|
||||
/// 该类型不接收原始响应正文,也不会在 `Debug` 或 `Display` 中打印上游
|
||||
/// 稳定诊断字段不接收原始响应正文,也不会在 `Debug` 或 `Display` 中打印上游
|
||||
/// code/request ID/response ID。Adapter 若捕获到可能含 secret 的上下文,只能调用
|
||||
/// [`ProviderError::redact_sensitive_context`] 丢弃正文并留下脱敏标记。
|
||||
///
|
||||
/// 唯一例外是 [`ProviderError::with_atomic_client_events`]:它只承载尚未交付客户端、
|
||||
/// 必须与本错误一起完成重试判断的协议事件。Core 会在任何持久化或日志记录前取走
|
||||
/// 这些事件,不能把它们当作诊断上下文使用。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderError {
|
||||
kind: ProviderErrorKind,
|
||||
@@ -284,8 +289,12 @@ pub struct ProviderError {
|
||||
retry_same_account: bool,
|
||||
sensitive_context_redacted: bool,
|
||||
client_visible_upstream_error: Option<Box<ClientVisibleUpstreamError>>,
|
||||
atomic_client_events: Option<Box<AtomicClientEvents>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AtomicClientEvents(Vec<ProviderEvent>);
|
||||
|
||||
impl ProviderError {
|
||||
/// 创建 Provider 错误。
|
||||
#[must_use]
|
||||
@@ -304,6 +313,7 @@ impl ProviderError {
|
||||
retry_same_account: false,
|
||||
sensitive_context_redacted: false,
|
||||
client_visible_upstream_error: None,
|
||||
atomic_client_events: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +389,17 @@ impl ProviderError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 附加必须先由 Core 判断重试、再决定丢弃或交付的客户端协议事件。
|
||||
///
|
||||
/// Provider 只能放入当前 attempt 尚未交付的事件;调用方应设置明确的有界缓冲,
|
||||
/// 并保证失败事件本身位于这批事件的末尾。
|
||||
#[must_use]
|
||||
pub fn with_atomic_client_events(mut self, events: Vec<ProviderEvent>) -> Self {
|
||||
self.atomic_client_events =
|
||||
(!events.is_empty()).then(|| Box::new(AtomicClientEvents(events)));
|
||||
self
|
||||
}
|
||||
|
||||
/// 丢弃敏感正文,只记录“发生过脱敏”这一事实。
|
||||
#[must_use]
|
||||
pub fn redact_sensitive_context(mut self, _sensitive_context: impl AsRef<str>) -> Self {
|
||||
@@ -462,6 +483,23 @@ impl ProviderError {
|
||||
pub fn client_visible_upstream_error(&self) -> Option<&ClientVisibleUpstreamError> {
|
||||
self.client_visible_upstream_error.as_deref()
|
||||
}
|
||||
|
||||
/// 取走只供本次重试/提交决策使用的原子客户端事件。
|
||||
///
|
||||
/// Core 必须在克隆错误、记录中间失败或构造终态前调用本方法,避免原始 wire
|
||||
/// 进入诊断或持久化对象。
|
||||
#[must_use]
|
||||
pub fn take_atomic_client_events(&mut self) -> Vec<ProviderEvent> {
|
||||
self.atomic_client_events
|
||||
.take()
|
||||
.map_or_else(Vec::new, |events| events.0)
|
||||
}
|
||||
|
||||
/// 返回是否携带尚未提交的原子客户端事件;不暴露事件内容。
|
||||
#[must_use]
|
||||
pub fn has_atomic_client_events(&self) -> bool {
|
||||
self.atomic_client_events.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderError {
|
||||
@@ -508,6 +546,13 @@ impl fmt::Debug for ProviderError {
|
||||
.as_ref()
|
||||
.map(|_| "<present>"),
|
||||
)
|
||||
.field(
|
||||
"atomic_client_events",
|
||||
&self
|
||||
.atomic_client_events
|
||||
.as_ref()
|
||||
.map_or(0, |events| events.0.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::engine::credential::{
|
||||
OpaqueProviderData, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use crate::error::{IdentifierError, validate_text};
|
||||
use crate::routing::ProviderKind;
|
||||
use crate::routing::{ProviderKind, UpstreamModelId};
|
||||
|
||||
const MAX_PENDING_FLOW_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
@@ -527,6 +527,80 @@ impl ProviderCooldown {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可丢失 cooldown 的细粒度作用域。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ProviderCooldownScope {
|
||||
/// 只阻止同一账号调用指定上游模型。
|
||||
UpstreamModel(UpstreamModelId),
|
||||
}
|
||||
|
||||
impl ProviderCooldownScope {
|
||||
#[must_use]
|
||||
pub const fn upstream_model(model: UpstreamModelId) -> Self {
|
||||
Self::UpstreamModel(model)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UpstreamModel(_) => "model",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
match self {
|
||||
Self::UpstreamModel(model) => model.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 不进入账号持久状态的账号+作用域 cooldown。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderScopedCooldown {
|
||||
account_id: ProviderAccountId,
|
||||
credential_revision: CredentialRevision,
|
||||
scope: ProviderCooldownScope,
|
||||
until: SystemTime,
|
||||
}
|
||||
|
||||
impl ProviderScopedCooldown {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
account_id: ProviderAccountId,
|
||||
credential_revision: CredentialRevision,
|
||||
scope: ProviderCooldownScope,
|
||||
until: SystemTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
account_id,
|
||||
credential_revision,
|
||||
scope,
|
||||
until,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn account_id(&self) -> &ProviderAccountId {
|
||||
&self.account_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn credential_revision(&self) -> CredentialRevision {
|
||||
self.credential_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn scope(&self) -> &ProviderCooldownScope {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn until(&self) -> SystemTime {
|
||||
self.until
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProviderCooldownPort: Send + Sync {
|
||||
fn put_if_later(
|
||||
&self,
|
||||
@@ -543,6 +617,24 @@ pub trait ProviderCooldownPort: Send + Sync {
|
||||
account_id: &'a ProviderAccountId,
|
||||
through_revision: CredentialRevision,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
|
||||
|
||||
fn put_scoped_if_later(
|
||||
&self,
|
||||
cooldown: ProviderScopedCooldown,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>>;
|
||||
|
||||
fn read_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>;
|
||||
|
||||
fn clear_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
through_revision: CredentialRevision,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -399,6 +399,69 @@ fn complete_stream(total_tokens: Option<u64>) -> Vec<Result<GatewayEvent, Provid
|
||||
events
|
||||
}
|
||||
|
||||
fn atomic_response_failed(response_id: &str, marker: &str) -> ProviderError {
|
||||
let started = ProviderEvent::canonical_with_wire(
|
||||
vec![GatewayEvent::Started(ResponseMeta::new(
|
||||
response_id,
|
||||
"gpt-5",
|
||||
))],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": response_id, "model": "gpt-5", "status": "in_progress"}
|
||||
}),
|
||||
)
|
||||
.expect("started wire"),
|
||||
);
|
||||
let failed = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": response_id,
|
||||
"status": "failed",
|
||||
"error": {"code": "rate_limit_exceeded", "message": marker}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("failed wire"),
|
||||
);
|
||||
ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
|
||||
.with_status(429)
|
||||
.with_replay_safe()
|
||||
.with_atomic_client_events(vec![started, failed])
|
||||
}
|
||||
|
||||
fn bare_atomic_response_failed(response_id: &str) -> ProviderError {
|
||||
let failed = ProviderEvent::canonical_with_wire(
|
||||
vec![GatewayEvent::Started(ResponseMeta::new(
|
||||
response_id,
|
||||
"fallback-model",
|
||||
))],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": response_id,
|
||||
"status": "failed",
|
||||
"error": {"code": "rate_limit_exceeded", "message": "bare failure"}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("bare failed wire"),
|
||||
);
|
||||
ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
|
||||
.with_status(429)
|
||||
.with_replay_safe()
|
||||
.with_atomic_client_events(vec![failed])
|
||||
}
|
||||
|
||||
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
|
||||
let mut usage = Usage::new();
|
||||
usage.input_tokens = Some(12);
|
||||
@@ -1837,6 +1900,277 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_response_failed_should_rotate_account_before_stream_commit() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(atomic_response_failed(
|
||||
"response-first-failed",
|
||||
"first account failure",
|
||||
))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let first = block_on(session.next_event())
|
||||
.expect("second account should produce the first delivery")
|
||||
.expect("started event");
|
||||
let delivered = first.into_provider_events();
|
||||
assert!(delivered.iter().flat_map(ProviderEvent::canonical_facts).any(
|
||||
|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "response-1")
|
||||
));
|
||||
assert!(
|
||||
!delivered
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.any(|wire| {
|
||||
wire.data().pointer("/response/id").and_then(Value::as_str)
|
||||
== Some("response-first-failed")
|
||||
})
|
||||
);
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit second account");
|
||||
while block_on(session.next_event())
|
||||
.expect("successful stream")
|
||||
.is_some()
|
||||
{}
|
||||
|
||||
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.commits, 1);
|
||||
assert_eq!(state.intermediate_failures, 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_atomic_response_failed_should_rotate_before_the_first_delivery() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(bare_atomic_response_failed("response-bare-failed"))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let first = block_on(session.next_event())
|
||||
.expect("second account should replace the bare failure")
|
||||
.expect("second account started event");
|
||||
assert!(first
|
||||
.into_provider_events()
|
||||
.iter()
|
||||
.flat_map(ProviderEvent::canonical_facts)
|
||||
.any(|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "response-1")));
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit second account");
|
||||
while block_on(session.next_event())
|
||||
.expect("successful stream")
|
||||
.is_some()
|
||||
{}
|
||||
|
||||
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.intermediate_failures, 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_atomic_response_failed_should_deliver_only_the_last_failure_once() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let scripts = (1..=route_plan.max_attempts().get())
|
||||
.map(|attempt| {
|
||||
let response_id = format!("response-attempt-{attempt}-failed");
|
||||
let marker = if attempt == route_plan.max_attempts().get() {
|
||||
"last upstream failure".to_owned()
|
||||
} else {
|
||||
format!("discarded failure {attempt}")
|
||||
};
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_failed",
|
||||
items: vec![Err(atomic_response_failed(&response_id, &marker))],
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let expected_attempts = route_plan.max_attempts().get();
|
||||
let expected_last_response = format!("response-attempt-{expected_attempts}-failed");
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let terminal = block_on(session.next_event())
|
||||
.expect("last failure remains deliverable")
|
||||
.expect("terminal failure batch");
|
||||
assert_eq!(
|
||||
terminal.commit_requirement(),
|
||||
CommitRequirement::CommitBeforeDelivery
|
||||
);
|
||||
let events = terminal.into_provider_events();
|
||||
let failures = events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.filter(|wire| wire.event_type() == Some("response.failed"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(
|
||||
failures[0]
|
||||
.data()
|
||||
.pointer("/response/id")
|
||||
.and_then(Value::as_str),
|
||||
Some(expected_last_response.as_str())
|
||||
);
|
||||
assert!(
|
||||
failures[0]
|
||||
.data()
|
||||
.to_string()
|
||||
.contains("last upstream failure")
|
||||
);
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit original terminal failure");
|
||||
let error = block_on(session.next_event()).expect_err("typed failure finalizes the request");
|
||||
assert!(matches!(error, EngineError::Provider(_)));
|
||||
assert!(session.is_finalized());
|
||||
|
||||
assert_eq!(
|
||||
provider.contexts.lock().expect("contexts lock").len(),
|
||||
usize::try_from(expected_attempts).expect("attempt count fits usize")
|
||||
);
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(
|
||||
state.intermediate_failures,
|
||||
usize::try_from(expected_attempts - 1).expect("attempt count fits usize")
|
||||
);
|
||||
assert_eq!(state.commits, 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
|
||||
assert!(state.finalizations[0].committed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_only",
|
||||
items: vec![Err(bare_atomic_response_failed("response-only-failed"))],
|
||||
},
|
||||
Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
)),
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let terminal = block_on(session.next_event())
|
||||
.expect("last account failure remains deliverable")
|
||||
.expect("terminal failure batch");
|
||||
let events = terminal.into_provider_events();
|
||||
let failure = events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.find(|wire| wire.event_type() == Some("response.failed"))
|
||||
.expect("last upstream response.failed");
|
||||
assert_eq!(
|
||||
failure
|
||||
.data()
|
||||
.pointer("/response/id")
|
||||
.and_then(Value::as_str),
|
||||
Some("response-only-failed")
|
||||
);
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit terminal failure");
|
||||
assert!(matches!(
|
||||
block_on(session.next_event()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
|
||||
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.attempts.len(), 1);
|
||||
assert_eq!(state.intermediate_failures, 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
|
||||
assert_eq!(
|
||||
state.finalizations[0].client_response_id.as_deref(),
|
||||
Some("response-only-failed")
|
||||
);
|
||||
assert_eq!(
|
||||
state.finalizations[0].upstream_response_id.as_deref(),
|
||||
Some("response-only-failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_response_failed_keeps_collect_uncommitted_error_semantics() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![Script::ObservedStream {
|
||||
account_id: "acct_required",
|
||||
items: vec![Err(atomic_response_failed(
|
||||
"response-required-failed",
|
||||
"required account failure",
|
||||
))],
|
||||
}]);
|
||||
let required = ProviderAccountId::new("acct_required").expect("required account");
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
Some(required),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let error = block_on(session.collect_uncommitted())
|
||||
.expect_err("non-streaming collection must keep returning the typed failure");
|
||||
assert!(matches!(error, EngineError::Provider(_)));
|
||||
assert!(session.is_finalized());
|
||||
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.commits, 0);
|
||||
assert_eq!(state.intermediate_failures, 0);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_continuation_explicit_429_is_not_retried() {
|
||||
let continuation = NativeContinuationPin::new(
|
||||
|
||||
@@ -2,6 +2,8 @@ use gateway_core::engine::UpstreamSendState;
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, GatewayError, ProviderError, ProviderErrorKind, SafeUpstreamValue,
|
||||
};
|
||||
use gateway_core::event::{ProtocolWireEvent, ProviderEvent};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_error_debug_should_not_expose_sensitive_context() {
|
||||
@@ -37,6 +39,26 @@ fn provider_error_replay_proof_should_be_explicit() {
|
||||
assert!(error.replay_is_safe());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_error_atomic_client_events_should_be_take_only_and_debug_redacted() {
|
||||
let marker = "atomic-wire-must-not-enter-debug";
|
||||
let event = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({"type": "response.failed", "message": marker}),
|
||||
)
|
||||
.expect("atomic wire"),
|
||||
);
|
||||
let mut error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
|
||||
.with_atomic_client_events(vec![event]);
|
||||
|
||||
assert!(error.has_atomic_client_events());
|
||||
assert!(!format!("{error:?}").contains(marker));
|
||||
assert_eq!(error.take_atomic_client_events().len(), 1);
|
||||
assert!(!error.has_atomic_client_events());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_error_should_keep_client_visible_upstream_fields_out_of_safe_diagnostics() {
|
||||
let message = "Your Codex quota is exhausted";
|
||||
|
||||
@@ -13,7 +13,8 @@ use gateway_core::{
|
||||
},
|
||||
error::StoreError as CoreStoreError,
|
||||
provider_ports::{
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderStoreError, ProviderStoreErrorKind,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderScopedCooldown,
|
||||
ProviderStoreError, ProviderStoreErrorKind,
|
||||
},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
@@ -104,24 +105,35 @@ impl RedisCredentialCooldownRepository {
|
||||
let fingerprint = resource_fingerprint("credential cooldown", provider_account_id)?;
|
||||
Ok(format!("{}:account:{fingerprint}:cooldown", self.namespace))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
|
||||
async fn cache_credential_cooldown(&self, cooldown: &CredentialCooldown) -> StoreResult<bool> {
|
||||
require_nonempty(
|
||||
"credential cooldown",
|
||||
"provider_account_id",
|
||||
&cooldown.provider_account_id,
|
||||
)?;
|
||||
let until_ms = cooldown.cooldown_until.timestamp_millis();
|
||||
fn scoped_key(
|
||||
&self,
|
||||
provider_account_id: &str,
|
||||
scope: &ProviderCooldownScope,
|
||||
) -> StoreResult<String> {
|
||||
let account_fingerprint = resource_fingerprint("credential cooldown", provider_account_id)?;
|
||||
let scope_fingerprint = resource_fingerprint("credential cooldown scope", scope.value())?;
|
||||
Ok(format!(
|
||||
"{}:account:{account_fingerprint}:cooldown:{}:{scope_fingerprint}",
|
||||
self.namespace,
|
||||
scope.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn cache_at_key(
|
||||
&self,
|
||||
key: String,
|
||||
credential_revision: Revision,
|
||||
cooldown_until: DateTime<Utc>,
|
||||
) -> StoreResult<bool> {
|
||||
let until_ms = cooldown_until.timestamp_millis();
|
||||
if until_ms <= 0 {
|
||||
return Err(invalid("cooldown expiry must be positive"));
|
||||
}
|
||||
let mut connection = self.connection.clone();
|
||||
let written = Script::new(WRITE_SCRIPT)
|
||||
.key(self.key(&cooldown.provider_account_id)?)
|
||||
.arg(cooldown.credential_revision.get())
|
||||
.key(key)
|
||||
.arg(credential_revision.get())
|
||||
.arg(until_ms)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
@@ -129,18 +141,10 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
|
||||
Ok(written == 1)
|
||||
}
|
||||
|
||||
async fn read_credential_cooldown(
|
||||
&self,
|
||||
provider_account_id: &str,
|
||||
) -> StoreResult<Option<CredentialCooldown>> {
|
||||
require_nonempty(
|
||||
"credential cooldown",
|
||||
"provider_account_id",
|
||||
provider_account_id,
|
||||
)?;
|
||||
async fn read_at_key(&self, key: String) -> StoreResult<Option<(Revision, DateTime<Utc>)>> {
|
||||
let mut connection = self.connection.clone();
|
||||
let (present, revision, until_ms): (i64, String, String) = Script::new(READ_SCRIPT)
|
||||
.key(self.key(provider_account_id)?)
|
||||
.key(key)
|
||||
.invoke_async(&mut connection)
|
||||
.await
|
||||
.map_err(|_| redis_unavailable("read credential cooldown"))?;
|
||||
@@ -155,11 +159,59 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
|
||||
.map_err(|_| invalid("cached cooldown expiry is invalid"))?;
|
||||
let cooldown_until = DateTime::from_timestamp_millis(until_ms)
|
||||
.ok_or_else(|| invalid("cached cooldown expiry is invalid"))?;
|
||||
Ok(Some(CredentialCooldown {
|
||||
provider_account_id: provider_account_id.to_owned(),
|
||||
credential_revision: Revision::new(revision)?,
|
||||
cooldown_until,
|
||||
}))
|
||||
Ok(Some((Revision::new(revision)?, cooldown_until)))
|
||||
}
|
||||
|
||||
async fn invalidate_at_key(
|
||||
&self,
|
||||
key: String,
|
||||
through_revision: Revision,
|
||||
) -> StoreResult<bool> {
|
||||
let mut connection = self.connection.clone();
|
||||
let removed = Script::new(INVALIDATE_SCRIPT)
|
||||
.key(key)
|
||||
.arg(through_revision.get())
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| redis_unavailable("invalidate credential cooldown"))?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
|
||||
async fn cache_credential_cooldown(&self, cooldown: &CredentialCooldown) -> StoreResult<bool> {
|
||||
require_nonempty(
|
||||
"credential cooldown",
|
||||
"provider_account_id",
|
||||
&cooldown.provider_account_id,
|
||||
)?;
|
||||
self.cache_at_key(
|
||||
self.key(&cooldown.provider_account_id)?,
|
||||
cooldown.credential_revision,
|
||||
cooldown.cooldown_until,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_credential_cooldown(
|
||||
&self,
|
||||
provider_account_id: &str,
|
||||
) -> StoreResult<Option<CredentialCooldown>> {
|
||||
require_nonempty(
|
||||
"credential cooldown",
|
||||
"provider_account_id",
|
||||
provider_account_id,
|
||||
)?;
|
||||
self.read_at_key(self.key(provider_account_id)?)
|
||||
.await
|
||||
.map(|value| {
|
||||
value.map(|(credential_revision, cooldown_until)| CredentialCooldown {
|
||||
provider_account_id: provider_account_id.to_owned(),
|
||||
credential_revision,
|
||||
cooldown_until,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn invalidate_credential_cooldown(
|
||||
@@ -172,14 +224,8 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
|
||||
"provider_account_id",
|
||||
provider_account_id,
|
||||
)?;
|
||||
let mut connection = self.connection.clone();
|
||||
let removed = Script::new(INVALIDATE_SCRIPT)
|
||||
.key(self.key(provider_account_id)?)
|
||||
.arg(through_revision.get())
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
self.invalidate_at_key(self.key(provider_account_id)?, through_revision)
|
||||
.await
|
||||
.map_err(|_| redis_unavailable("invalidate credential cooldown"))?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +287,69 @@ impl ProviderCooldownPort for RedisCredentialCooldownRepository {
|
||||
.map_err(|_| provider_unavailable("clear credential cooldown"))
|
||||
})
|
||||
}
|
||||
|
||||
fn put_scoped_if_later(
|
||||
&self,
|
||||
cooldown: ProviderScopedCooldown,
|
||||
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let revision = Revision::new(cooldown.credential_revision().get())
|
||||
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?;
|
||||
self.cache_at_key(
|
||||
self.scoped_key(cooldown.account_id().as_str(), cooldown.scope())
|
||||
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
|
||||
revision,
|
||||
cooldown.until().into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("cache scoped credential cooldown"))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
) -> futures::future::BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
self.read_at_key(
|
||||
self.scoped_key(account_id.as_str(), scope)
|
||||
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("read scoped credential cooldown"))?
|
||||
.map(|(revision, until)| {
|
||||
Ok(ProviderScopedCooldown::new(
|
||||
account_id.clone(),
|
||||
CredentialRevision::new(revision.get())
|
||||
.map_err(|_| provider_invalid("decode scoped credential cooldown"))?,
|
||||
scope.clone(),
|
||||
until.into(),
|
||||
))
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
through_revision: CredentialRevision,
|
||||
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let revision = Revision::new(through_revision.get())
|
||||
.map_err(|_| provider_invalid("encode scoped cooldown revision"))?;
|
||||
self.invalidate_at_key(
|
||||
self.scoped_key(account_id.as_str(), scope)
|
||||
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("clear scoped credential cooldown"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号状态写入到可丢失 cooldown cache 的桥接 adapter。
|
||||
|
||||
@@ -13,6 +13,7 @@ use gateway_core::{
|
||||
QuotaObservation, QuotaWriteOutcome,
|
||||
},
|
||||
error::{StoreError as CoreStoreError, StoreErrorKind},
|
||||
provider_ports::{ProviderCooldownPort, ProviderCooldownScope, ProviderScopedCooldown},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
use gateway_store::{
|
||||
@@ -530,6 +531,109 @@ async fn credential_cooldown_read_removes_expired_grace_key() {
|
||||
assert!(namespace_keys(&mut connection, &namespace).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_cooldown_isolated_by_model_and_revision_fenced() {
|
||||
let Some((repository, mut connection, namespace)) = repository().await else {
|
||||
return;
|
||||
};
|
||||
let account_id = ProviderAccountId::new("acct_scoped_cooldown").expect("valid account ID");
|
||||
let revision = CredentialRevision::new(2).expect("positive revision");
|
||||
let model_a = ProviderCooldownScope::upstream_model(
|
||||
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
);
|
||||
let model_b = ProviderCooldownScope::upstream_model(
|
||||
gateway_core::routing::UpstreamModelId::new("grok-4.6").expect("model"),
|
||||
);
|
||||
let until_a: SystemTime = millisecond_precision(Utc::now() + Duration::seconds(30)).into();
|
||||
let until_b: SystemTime = millisecond_precision(Utc::now() + Duration::seconds(60)).into();
|
||||
|
||||
assert!(
|
||||
repository
|
||||
.put_scoped_if_later(ProviderScopedCooldown::new(
|
||||
account_id.clone(),
|
||||
revision,
|
||||
model_a.clone(),
|
||||
until_a,
|
||||
))
|
||||
.await
|
||||
.expect("cache model A cooldown")
|
||||
);
|
||||
assert!(
|
||||
!repository
|
||||
.put_scoped_if_later(ProviderScopedCooldown::new(
|
||||
account_id.clone(),
|
||||
CredentialRevision::new(1).expect("stale revision"),
|
||||
model_a.clone(),
|
||||
until_b,
|
||||
))
|
||||
.await
|
||||
.expect("reject stale model A cooldown")
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.put_scoped_if_later(ProviderScopedCooldown::new(
|
||||
account_id.clone(),
|
||||
revision,
|
||||
model_b.clone(),
|
||||
until_b,
|
||||
))
|
||||
.await
|
||||
.expect("cache model B cooldown")
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.read_scoped(&account_id, &model_a)
|
||||
.await
|
||||
.expect("read model A cooldown")
|
||||
.expect("model A cooldown")
|
||||
.until(),
|
||||
until_a
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.read_scoped(&account_id, &model_b)
|
||||
.await
|
||||
.expect("read model B cooldown")
|
||||
.expect("model B cooldown")
|
||||
.until(),
|
||||
until_b
|
||||
);
|
||||
assert!(
|
||||
!repository
|
||||
.clear_scoped(
|
||||
&account_id,
|
||||
&model_a,
|
||||
CredentialRevision::new(1).expect("stale revision"),
|
||||
)
|
||||
.await
|
||||
.expect("fence stale model A invalidation")
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.clear_scoped(&account_id, &model_a, revision)
|
||||
.await
|
||||
.expect("clear model A cooldown")
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.read_scoped(&account_id, &model_a)
|
||||
.await
|
||||
.expect("read cleared model A cooldown")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.read_scoped(&account_id, &model_b)
|
||||
.await
|
||||
.expect("read retained model B cooldown")
|
||||
.is_some()
|
||||
);
|
||||
let keys = namespace_keys(&mut connection, &namespace).await;
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert!(!keys[0].contains(account_id.as_str()));
|
||||
assert!(!keys[0].contains(model_b.value()));
|
||||
}
|
||||
|
||||
fn cooldown(account_id: &str, revision: u64, seconds: i64) -> CredentialCooldown {
|
||||
CredentialCooldown {
|
||||
provider_account_id: account_id.to_owned(),
|
||||
|
||||
@@ -178,6 +178,14 @@ impl CodexQuotaFact {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_fact_is_active_exhaustion(fact: CodexQuotaFact, now: SystemTime) -> bool {
|
||||
fact.exhausted()
|
||||
&& fact
|
||||
.resets_at()
|
||||
.map(SystemTime::from)
|
||||
.is_none_or(|reset_at| reset_at > now)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct CodexQuotaSyncSummary {
|
||||
pub updated: u64,
|
||||
@@ -502,10 +510,20 @@ fn quota_refresh_candidate(
|
||||
return None;
|
||||
}
|
||||
let observation = observations.and_then(|observations| observations.get(account.id()));
|
||||
let snapshot = quota_snapshot_for_account(&account, observation);
|
||||
let kind = match account.availability() {
|
||||
AccountAvailability::QuotaExhausted => CodexQuotaRefreshKind::Exhausted {
|
||||
reset_at: quota_reset_at_from_observation(&account, observation),
|
||||
reset_at: quota_reset_at_from_snapshot(snapshot.as_ref()),
|
||||
},
|
||||
AccountAvailability::Cooldown
|
||||
if snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.fact().exhausted()) =>
|
||||
{
|
||||
CodexQuotaRefreshKind::Exhausted {
|
||||
reset_at: quota_reset_at_from_snapshot(snapshot.as_ref()),
|
||||
}
|
||||
}
|
||||
AccountAvailability::Ready
|
||||
if observations.is_some() && quota_observation_is_stale(&account, observation, now) =>
|
||||
{
|
||||
@@ -516,17 +534,32 @@ fn quota_refresh_candidate(
|
||||
Some(CodexQuotaRefreshCandidate { account, kind })
|
||||
}
|
||||
|
||||
fn quota_reset_at_from_observation(
|
||||
fn quota_snapshot_for_account(
|
||||
account: &ProviderAccount,
|
||||
observation: Option<&QuotaObservation>,
|
||||
) -> Option<SystemTime> {
|
||||
) -> Option<CodexAccountQuotaSnapshot> {
|
||||
observation
|
||||
.filter(|observation| observation.expected_revision == account.revision())
|
||||
.and_then(quota_snapshot_from_observation)
|
||||
}
|
||||
|
||||
fn quota_reset_at_from_snapshot(
|
||||
snapshot: Option<&CodexAccountQuotaSnapshot>,
|
||||
) -> Option<SystemTime> {
|
||||
snapshot
|
||||
.and_then(|snapshot| snapshot.fact().resets_at())
|
||||
.map(SystemTime::from)
|
||||
}
|
||||
|
||||
fn quota_observation_confirms_active_exhaustion(
|
||||
account: &ProviderAccount,
|
||||
observation: Option<&QuotaObservation>,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
quota_snapshot_for_account(account, observation)
|
||||
.is_some_and(|snapshot| quota_fact_is_active_exhaustion(snapshot.fact(), now))
|
||||
}
|
||||
|
||||
fn quota_observation_is_stale(
|
||||
account: &ProviderAccount,
|
||||
observation: Option<&QuotaObservation>,
|
||||
@@ -674,7 +707,7 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
|
||||
pub async fn synchronize(&self) -> Result<CodexQuotaSyncSummary, CodexCredentialQuotaError> {
|
||||
let accounts = self.repository.list_for_provider().await?;
|
||||
let mut accounts = self.repository.list_for_provider().await?;
|
||||
let mut summary = CodexQuotaSyncSummary::default();
|
||||
let now = SystemTime::now();
|
||||
let account_ids = accounts
|
||||
@@ -694,6 +727,8 @@ impl CodexCredentialQuotaService {
|
||||
None
|
||||
}
|
||||
};
|
||||
self.reconcile_persisted_exhaustion(&mut accounts, observations.as_ref(), now)
|
||||
.await;
|
||||
let accounts =
|
||||
self.scheduling
|
||||
.reserve_periodic_refreshes(accounts, observations.as_ref(), now);
|
||||
@@ -730,6 +765,9 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
Err(CodexQuotaFetchError::Upstream { account, error }) => {
|
||||
let (availability, reason, cooldown) = classify_error(&error, observed_at);
|
||||
let preserve_exhaustion = account.availability()
|
||||
== AccountAvailability::QuotaExhausted
|
||||
&& availability == Some(AccountAvailability::Cooldown);
|
||||
match availability {
|
||||
Some(AccountAvailability::Invalid | AccountAvailability::Banned) => {
|
||||
summary.invalid += 1;
|
||||
@@ -741,6 +779,9 @@ impl CodexCredentialQuotaService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if preserve_exhaustion {
|
||||
continue;
|
||||
}
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(
|
||||
@@ -757,6 +798,63 @@ impl CodexCredentialQuotaService {
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn reconcile_persisted_exhaustion(
|
||||
&self,
|
||||
accounts: &mut [ProviderAccount],
|
||||
observations: Option<&BTreeMap<ProviderAccountId, QuotaObservation>>,
|
||||
observed_at: SystemTime,
|
||||
) {
|
||||
let Some(observations) = observations else {
|
||||
return;
|
||||
};
|
||||
for account in accounts {
|
||||
if account.availability() != AccountAvailability::Cooldown
|
||||
|| !quota_observation_confirms_active_exhaustion(
|
||||
account,
|
||||
observations.get(account.id()),
|
||||
observed_at,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(current)) = self.store.get_account(account.id()).await else {
|
||||
continue;
|
||||
};
|
||||
if current.revision() != account.revision()
|
||||
|| current.availability() != AccountAvailability::Cooldown
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !matches!(
|
||||
self.has_active_exhaustion(¤t, observed_at).await,
|
||||
Ok(true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = self
|
||||
.repository
|
||||
.apply_state(
|
||||
¤t,
|
||||
AccountAvailability::QuotaExhausted,
|
||||
Some("quota_exhausted".to_owned()),
|
||||
None,
|
||||
observed_at,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
error = %error,
|
||||
"OpenAI persisted quota exhaustion reconciliation failed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let enabled = current.enabled();
|
||||
*account =
|
||||
current.with_runtime_state(enabled, AccountAvailability::QuotaExhausted, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
|
||||
async fn apply_fetched_quota(
|
||||
&self,
|
||||
@@ -894,6 +992,30 @@ impl CodexCredentialQuotaService {
|
||||
.await?
|
||||
.filter(|account| account.provider().as_str() == "openai")
|
||||
.ok_or(CodexCredentialQuotaError::NotFound)?;
|
||||
self.read_snapshot_for(&account).await
|
||||
}
|
||||
|
||||
pub(super) async fn has_active_exhaustion(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
now: SystemTime,
|
||||
) -> Result<bool, CodexCredentialQuotaError> {
|
||||
match self.read_snapshot_for(account).await {
|
||||
Ok(snapshot) => Ok(snapshot
|
||||
.is_some_and(|snapshot| quota_fact_is_active_exhaustion(snapshot.fact(), now))),
|
||||
Err(CodexCredentialQuotaError::RevisionConflict) => Ok(false),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_snapshot_for(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
) -> Result<Option<CodexAccountQuotaSnapshot>, CodexCredentialQuotaError> {
|
||||
if account.provider().as_str() != "openai" {
|
||||
return Err(CodexCredentialQuotaError::NotFound);
|
||||
}
|
||||
let account_id = account.id();
|
||||
let Some(observation) = self
|
||||
.store
|
||||
.get_quotas(std::slice::from_ref(account_id))
|
||||
|
||||
@@ -552,13 +552,9 @@ impl CodexCredentialSelector {
|
||||
CookieRecovery::None,
|
||||
),
|
||||
CodexAccountFailure::RateLimited { retry_after } => {
|
||||
let until = now.checked_add(retry_after.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN));
|
||||
(
|
||||
AccountAvailability::Cooldown,
|
||||
Some("rate_limited".to_owned()),
|
||||
until,
|
||||
CookieRecovery::None,
|
||||
)
|
||||
return self
|
||||
.record_rate_limit_failure(account, retry_after, now)
|
||||
.await;
|
||||
}
|
||||
CodexAccountFailure::CloudflareChallenge { retry_after } => {
|
||||
let delay = self.cloudflare_challenge_delay(account.id(), now, retry_after);
|
||||
@@ -596,6 +592,49 @@ impl CodexCredentialSelector {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_rate_limit_failure(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
retry_after: Option<Duration>,
|
||||
observed_at: SystemTime,
|
||||
) -> Result<(), CredentialSelectionError> {
|
||||
let current = self.current_account(account.id()).await?;
|
||||
if current.revision() != account.revision() {
|
||||
return Err(CredentialRepositoryError::RevisionConflict.into());
|
||||
}
|
||||
if matches!(
|
||||
current.availability(),
|
||||
AccountAvailability::Invalid
|
||||
| AccountAvailability::Expired
|
||||
| AccountAvailability::Banned
|
||||
| AccountAvailability::QuotaExhausted
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
let quota_exhausted = self
|
||||
.quota
|
||||
.has_active_exhaustion(¤t, observed_at)
|
||||
.await
|
||||
.map_err(|_| CredentialSelectionError::Store)?;
|
||||
let (availability, reason, cooldown_until) = if quota_exhausted {
|
||||
(
|
||||
AccountAvailability::QuotaExhausted,
|
||||
Some("quota_exhausted".to_owned()),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
AccountAvailability::Cooldown,
|
||||
Some("rate_limited".to_owned()),
|
||||
observed_at.checked_add(retry_after.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)),
|
||||
)
|
||||
};
|
||||
self.repository
|
||||
.apply_state(¤t, availability, reason, cooldown_until, observed_at)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_success(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
|
||||
@@ -79,6 +79,8 @@ const PROVIDER_NAME: &str = "openai";
|
||||
const HTTP_SSE_TRANSPORT: &str = "http_sse";
|
||||
const WEBSOCKET_TRANSPORT: &str = "websocket";
|
||||
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
|
||||
/// 与 2.x 预取器一致:提交边界前最多保留 64 KiB 原始上游 chunk。
|
||||
const MAX_STREAM_PREFETCH_BYTES: usize = 64 * 1024;
|
||||
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
|
||||
pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
|
||||
|
||||
@@ -684,6 +686,8 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
})?;
|
||||
let request_id = context.request_id().as_str().to_owned();
|
||||
let cancellation = context.cancellation().clone();
|
||||
let stream_commit_policy =
|
||||
StreamCommitPolicy::for_continuation(context.continuation_attempt());
|
||||
let mut response = create_response_attempt(
|
||||
&client,
|
||||
&request,
|
||||
@@ -824,6 +828,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
// (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。
|
||||
let mut decoder =
|
||||
CodexCanonicalDecoder::new(upstream_model.as_str()).with_raw_sse_passthrough();
|
||||
let mut pre_commit_events = PreCommitClientEvents::new(stream_commit_policy);
|
||||
loop {
|
||||
let Some(stream_deadline) = remaining(context.deadline()) else {
|
||||
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
|
||||
@@ -882,30 +887,41 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
}
|
||||
let first_event_changed =
|
||||
observation_state.observe_stream_chunk(&chunk, context.timing_started_at());
|
||||
let (mut events, terminal_failure) = match decoder.push(&chunk) {
|
||||
let chunk_len = chunk.len();
|
||||
let (mut events, canonical_failure) = match decoder.push(&chunk) {
|
||||
CodexCanonicalOutcome::Events(events) => (events, None),
|
||||
CodexCanonicalOutcome::Failed(failure) => {
|
||||
let (events, error, semantic_output_seen) = failure.into_parts();
|
||||
let failure = map_canonical_error(
|
||||
(events, Some((error, semantic_output_seen)))
|
||||
}
|
||||
};
|
||||
pre_commit_events.observe_chunk(chunk_len);
|
||||
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
|
||||
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
|
||||
(
|
||||
map_canonical_error(
|
||||
error,
|
||||
&failure_diagnostics,
|
||||
&failure_set_cookie_headers,
|
||||
&failure_rate_limit_headers,
|
||||
ReplayBoundary::from_semantic_output(semantic_output_seen),
|
||||
);
|
||||
(events, Some(failure))
|
||||
}
|
||||
};
|
||||
ReplayBoundary::from_semantic_output(
|
||||
semantic_output_seen || pre_commit_events.is_committed(),
|
||||
),
|
||||
),
|
||||
atomic_upstream_failure,
|
||||
)
|
||||
});
|
||||
let timing_signals = decoder.take_timing_signals();
|
||||
let timing_changed = first_event_changed
|
||||
|| observation_state
|
||||
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
|
||||
.observe_timing_signals(timing_signals, context.timing_started_at());
|
||||
let completed = events
|
||||
.iter()
|
||||
.flat_map(ProviderEvent::canonical_facts)
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
let terminal_changed = completed
|
||||
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
|
||||
if let Some(failure) = terminal_failure.as_ref() {
|
||||
if let Some((failure, _)) = terminal_failure.as_ref() {
|
||||
apply_failure(&failure_context, &active_account, failure)
|
||||
.await;
|
||||
}
|
||||
@@ -933,33 +949,54 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if rate_limits_changed || timing_changed || terminal_changed {
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
}
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
if let Some(failure) = terminal_failure {
|
||||
if let Some((mut failure, atomic_upstream_failure)) = terminal_failure {
|
||||
let failure_after_commit =
|
||||
timing_signals.semantic_output || pre_commit_events.is_committed();
|
||||
if failure_after_commit {
|
||||
for event in pre_commit_events.commit(events) {
|
||||
yield event;
|
||||
}
|
||||
} else if atomic_upstream_failure {
|
||||
failure.error = failure
|
||||
.error
|
||||
.with_atomic_client_events(pre_commit_events.take_for_failure(events));
|
||||
}
|
||||
Err(failure.error)?;
|
||||
return;
|
||||
}
|
||||
let events = pre_commit_events.stage(events, timing_signals, completed)?;
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
if completed {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let (mut events, terminal_failure) = match decoder.finish() {
|
||||
let (mut events, canonical_failure) = match decoder.finish() {
|
||||
CodexCanonicalOutcome::Events(events) => (events, None),
|
||||
CodexCanonicalOutcome::Failed(failure) => {
|
||||
let (events, error, semantic_output_seen) = failure.into_parts();
|
||||
let failure = map_canonical_error(
|
||||
(events, Some((error, semantic_output_seen)))
|
||||
}
|
||||
};
|
||||
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
|
||||
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
|
||||
(
|
||||
map_canonical_error(
|
||||
error,
|
||||
&failure_diagnostics,
|
||||
&failure_set_cookie_headers,
|
||||
&failure_rate_limit_headers,
|
||||
ReplayBoundary::from_semantic_output(semantic_output_seen),
|
||||
);
|
||||
(events, Some(failure))
|
||||
}
|
||||
};
|
||||
ReplayBoundary::from_semantic_output(
|
||||
semantic_output_seen || pre_commit_events.is_committed(),
|
||||
),
|
||||
),
|
||||
atomic_upstream_failure,
|
||||
)
|
||||
});
|
||||
let timing_signals = decoder.take_timing_signals();
|
||||
let timing_changed = observation_state
|
||||
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
|
||||
.observe_timing_signals(timing_signals, context.timing_started_at());
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
let rate_limits_changed = if updates.is_empty() {
|
||||
false
|
||||
@@ -969,7 +1006,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
}
|
||||
observation_state.merge_rate_limit_headers(&updates)
|
||||
};
|
||||
if let Some(failure) = terminal_failure.as_ref() {
|
||||
if let Some((failure, _)) = terminal_failure.as_ref() {
|
||||
apply_failure(&failure_context, &active_account, failure)
|
||||
.await;
|
||||
}
|
||||
@@ -1008,13 +1045,25 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if timing_changed || rate_limits_changed || terminal_changed {
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
}
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
if let Some(failure) = terminal_failure {
|
||||
if let Some((mut failure, atomic_upstream_failure)) = terminal_failure {
|
||||
let failure_after_commit =
|
||||
timing_signals.semantic_output || pre_commit_events.is_committed();
|
||||
if failure_after_commit {
|
||||
for event in pre_commit_events.commit(events) {
|
||||
yield event;
|
||||
}
|
||||
} else if atomic_upstream_failure {
|
||||
failure.error = failure
|
||||
.error
|
||||
.with_atomic_client_events(pre_commit_events.take_for_failure(events));
|
||||
}
|
||||
Err(failure.error)?;
|
||||
return;
|
||||
}
|
||||
let events = pre_commit_events.finish(events, timing_signals, completed)?;
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1735,6 +1784,128 @@ impl MappedProviderFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 2.x 一致的流提交策略:普通请求优先首事件,续写/重放优先可恢复性。
|
||||
#[derive(Clone, Copy)]
|
||||
enum StreamCommitPolicy {
|
||||
FirstForwardableEvent,
|
||||
UntilOutputOrTerminal,
|
||||
}
|
||||
|
||||
impl StreamCommitPolicy {
|
||||
const fn for_continuation(attempt: ContinuationAttempt) -> Self {
|
||||
match attempt {
|
||||
ContinuationAttempt::None => Self::FirstForwardableEvent,
|
||||
ContinuationAttempt::Native
|
||||
| ContinuationAttempt::ReplayOwner
|
||||
| ContinuationAttempt::ReplayAny => Self::UntilOutputOrTerminal,
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_reached(
|
||||
self,
|
||||
events: &[ProviderEvent],
|
||||
timing_signals: ResponseEventSignals,
|
||||
completed: bool,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::FirstForwardableEvent => events.iter().any(ProviderEvent::has_client_event),
|
||||
Self::UntilOutputOrTerminal => timing_signals.semantic_output || completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交边界前的上游事件预取。
|
||||
///
|
||||
/// 原始 chunk 计数而不是重编码后的 event 大小,保持 2.x 的 64 KiB 资源边界;一旦
|
||||
/// 提交,后续事件不再具备无痕重放资格。
|
||||
struct PreCommitClientEvents {
|
||||
policy: StreamCommitPolicy,
|
||||
pending: Vec<ProviderEvent>,
|
||||
prefetched_bytes: usize,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl PreCommitClientEvents {
|
||||
const fn new(policy: StreamCommitPolicy) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
pending: Vec::new(),
|
||||
prefetched_bytes: 0,
|
||||
committed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_chunk(&mut self, bytes: usize) {
|
||||
if !self.committed {
|
||||
self.prefetched_bytes = self.prefetched_bytes.saturating_add(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn stage(
|
||||
&mut self,
|
||||
incoming: Vec<ProviderEvent>,
|
||||
timing_signals: ResponseEventSignals,
|
||||
completed: bool,
|
||||
) -> Result<Vec<ProviderEvent>, ProviderError> {
|
||||
if self.committed {
|
||||
return Ok(incoming);
|
||||
}
|
||||
let boundary_reached = self
|
||||
.policy
|
||||
.boundary_reached(&incoming, timing_signals, completed);
|
||||
self.pending.extend(incoming);
|
||||
if boundary_reached {
|
||||
return Ok(self.commit_pending());
|
||||
}
|
||||
if self.prefetched_bytes > MAX_STREAM_PREFETCH_BYTES {
|
||||
return Err(prefetch_protocol_error());
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
incoming: Vec<ProviderEvent>,
|
||||
timing_signals: ResponseEventSignals,
|
||||
completed: bool,
|
||||
) -> Result<Vec<ProviderEvent>, ProviderError> {
|
||||
let events = self.stage(incoming, timing_signals, completed)?;
|
||||
if self.committed {
|
||||
Ok(events)
|
||||
} else {
|
||||
Err(prefetch_protocol_error())
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
|
||||
if self.committed {
|
||||
return incoming;
|
||||
}
|
||||
self.pending.extend(incoming);
|
||||
self.commit_pending()
|
||||
}
|
||||
|
||||
fn take_for_failure(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
|
||||
self.pending.extend(incoming);
|
||||
self.prefetched_bytes = 0;
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
|
||||
const fn is_committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
|
||||
fn commit_pending(&mut self) -> Vec<ProviderEvent> {
|
||||
self.committed = true;
|
||||
self.prefetched_bytes = 0;
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
fn prefetch_protocol_error() -> ProviderError {
|
||||
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ReplayBoundary {
|
||||
BeforeSemanticOutput,
|
||||
|
||||
@@ -273,8 +273,20 @@ impl CodexCanonicalDecoder {
|
||||
self.merge_timing_signals(signals);
|
||||
if matches!(event_type, Some("response.failed" | "error")) {
|
||||
let failure = ResponsesSseFailure::from_event(event_type.unwrap_or_default(), &value);
|
||||
let mut canonical = Vec::new();
|
||||
if !self.started {
|
||||
// 失败首帧可能是唯一携带 response_id 的上游事实。身份投影只做
|
||||
// 最佳努力:缺少 response/id 时仍必须保留原 typed failure。
|
||||
let _ = self.start(&value, &mut canonical);
|
||||
}
|
||||
if let Some(wire) = Self::wire_for_event(event, value, raw_sse_frame) {
|
||||
output.push(ProviderEvent::wire(wire));
|
||||
output.push(if canonical.is_empty() {
|
||||
ProviderEvent::wire(wire)
|
||||
} else {
|
||||
ProviderEvent::canonical_with_wire(canonical, wire)
|
||||
});
|
||||
} else {
|
||||
output.extend(canonical.into_iter().map(ProviderEvent::canonical));
|
||||
}
|
||||
return Err(CodexCanonicalError::Upstream(failure));
|
||||
}
|
||||
|
||||
@@ -415,15 +415,6 @@ fn classify_upstream_failure(
|
||||
if status == Some(StatusCode::NOT_FOUND) && body.trim().is_empty() {
|
||||
return CodexFailureCategory::CloudflarePathBlocked;
|
||||
}
|
||||
if [code.as_str(), error_type.as_str()]
|
||||
.into_iter()
|
||||
.any(is_rate_limit_signal)
|
||||
|| is_rate_limit_message(&message)
|
||||
|| is_rate_limit_message(&body)
|
||||
|| status == Some(StatusCode::TOO_MANY_REQUESTS)
|
||||
{
|
||||
return CodexFailureCategory::RateLimited;
|
||||
}
|
||||
if [code.as_str(), error_type.as_str()]
|
||||
.into_iter()
|
||||
.any(is_quota_signal)
|
||||
@@ -433,6 +424,15 @@ fn classify_upstream_failure(
|
||||
{
|
||||
return CodexFailureCategory::QuotaExhausted;
|
||||
}
|
||||
if [code.as_str(), error_type.as_str()]
|
||||
.into_iter()
|
||||
.any(is_rate_limit_signal)
|
||||
|| is_rate_limit_message(&message)
|
||||
|| is_rate_limit_message(&body)
|
||||
|| status == Some(StatusCode::TOO_MANY_REQUESTS)
|
||||
{
|
||||
return CodexFailureCategory::RateLimited;
|
||||
}
|
||||
if is_upstream_overload(&code) || is_upstream_overload(&message) {
|
||||
return CodexFailureCategory::Unavailable;
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderStoreError, ProviderStorePorts,
|
||||
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError,
|
||||
ProviderStorePorts,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
|
||||
@@ -901,6 +902,30 @@ impl ProviderCooldownPort for TestCooldown {
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn put_scoped_if_later(
|
||||
&self,
|
||||
_cooldown: ProviderScopedCooldown,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn read_scoped<'a>(
|
||||
&'a self,
|
||||
_account_id: &'a ProviderAccountId,
|
||||
_scope: &'a ProviderCooldownScope,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
|
||||
fn clear_scoped<'a>(
|
||||
&'a self,
|
||||
_account_id: &'a ProviderAccountId,
|
||||
_scope: &'a ProviderCooldownScope,
|
||||
_through_revision: CredentialRevision,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRuntimePolicy;
|
||||
|
||||
@@ -7,7 +7,8 @@ use chrono::Utc;
|
||||
use futures::executor::block_on;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAttemptFeedback, AccountAvailability, AccountFeedbackStats, AccountSelectionPolicy,
|
||||
ProviderAccountId, RotationStrategy,
|
||||
AccountStateChange, OpaqueProviderData, ProviderAccountId, ProviderAccountStore as _,
|
||||
QuotaObservation, RotationStrategy,
|
||||
};
|
||||
use gateway_core::engine::{
|
||||
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
|
||||
@@ -23,6 +24,7 @@ use provider_openai::credential::{
|
||||
};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde_json::json;
|
||||
use url::Url;
|
||||
|
||||
use crate::support::{
|
||||
@@ -637,6 +639,171 @@ fn rate_limited_failure_marks_account_cooldown_with_a_cooldown_deadline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_failure_does_not_downgrade_persisted_quota_exhaustion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
let account = store.account("acct_primary").expect("account");
|
||||
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
block_on(
|
||||
store.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
}),
|
||||
)
|
||||
.expect("persist exhausted quota");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(selector.record_failure(
|
||||
&account,
|
||||
CodexAccountFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
},
|
||||
))
|
||||
.expect("record rate-limit failure");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("persisted account")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_failure_ignores_exhaustion_after_its_reset_time() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
let account = store.account("acct_primary").expect("account");
|
||||
let reset_at = Utc::now().timestamp() - 1;
|
||||
block_on(
|
||||
store.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
}),
|
||||
)
|
||||
.expect("persist expired quota exhaustion");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(selector.record_failure(
|
||||
&account,
|
||||
CodexAccountFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
},
|
||||
))
|
||||
.expect("record rate-limit failure");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("persisted account")
|
||||
.availability(),
|
||||
AccountAvailability::Cooldown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_failure_preserves_terminal_account_state() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
let account = store.account("acct_primary").expect("account");
|
||||
block_on(store.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::Invalid,
|
||||
reason: Some("identity_verification_required".to_owned()),
|
||||
cooldown_until: None,
|
||||
observed_at: SystemTime::now(),
|
||||
}))
|
||||
.expect("mark account invalid");
|
||||
let current = store.account("acct_primary").expect("invalid account");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(selector.record_failure(
|
||||
¤t,
|
||||
CodexAccountFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
},
|
||||
))
|
||||
.expect("record rate-limit failure");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("persisted account")
|
||||
.availability(),
|
||||
AccountAvailability::Invalid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_failures_for_distinct_accounts_do_not_conflict() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_first", "at-first");
|
||||
create_account(&store, "acct_second", "at-second");
|
||||
let first = store.account("acct_first").expect("first account");
|
||||
let second = store.account("acct_second").expect("second account");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(async {
|
||||
let (first_result, second_result) = futures::join!(
|
||||
selector.record_failure(
|
||||
&first,
|
||||
CodexAccountFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
},
|
||||
),
|
||||
selector.record_failure(
|
||||
&second,
|
||||
CodexAccountFailure::RateLimited {
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
},
|
||||
),
|
||||
);
|
||||
first_result.expect("record first account failure");
|
||||
second_result.expect("record second account failure");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
[
|
||||
store
|
||||
.account("acct_first")
|
||||
.expect("persisted first account")
|
||||
.availability(),
|
||||
store
|
||||
.account("acct_second")
|
||||
.expect("persisted second account")
|
||||
.availability(),
|
||||
],
|
||||
[AccountAvailability::Cooldown; 2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_keeps_a_quota_exhausted_account_eligible_when_configured_not_to_skip_it() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -480,6 +480,148 @@ async fn periodic_quota_synchronization_skips_fresh_ready_accounts() {
|
||||
assert_eq!(summary, CodexQuotaSyncSummary::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_repairs_cooldown_with_active_exhausted_quota() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_stuck_cooldown").await;
|
||||
let account = store
|
||||
.account("acct_periodic_stuck_cooldown")
|
||||
.expect("created account");
|
||||
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::Cooldown,
|
||||
reason: Some("rate_limited".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(24 * 60 * 60)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account cooldown");
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_stuck_cooldown")
|
||||
.expect("reconciled account")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_limit() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_exhausted").await;
|
||||
let account = store
|
||||
.account("acct_periodic_exhausted")
|
||||
.expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("quota_exhausted".to_owned()),
|
||||
cooldown_until: None,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_exhausted")
|
||||
.expect("persisted account")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_skips_ordinary_cooldown_without_exhausted_quota() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_temporary_cooldown").await;
|
||||
let account = store
|
||||
.account("acct_periodic_temporary_cooldown")
|
||||
.expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::Cooldown,
|
||||
reason: Some("rate_limited".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(60)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account cooldown");
|
||||
|
||||
let summary = blocked_network_quota_service(&store)
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("ordinary cooldown must not query upstream");
|
||||
|
||||
assert_eq!(summary, CodexQuotaSyncSummary::default());
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_temporary_cooldown")
|
||||
.expect("cooldown account")
|
||||
.availability(),
|
||||
AccountAvailability::Cooldown
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_limits_stale_ready_account_scans() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -10,7 +10,7 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::engine::provider::{Provider as _, ProviderRequest};
|
||||
use gateway_core::engine::{
|
||||
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId,
|
||||
AccountAttemptContext, AttemptContext, CancellationToken, ContinuationAttempt, ModelRequestId,
|
||||
RequestAttemptContext, UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::ProviderErrorKind;
|
||||
@@ -29,6 +29,12 @@ use provider_openai::transport::CodexWebSocketPool;
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::{CodexProvider, OFFICIAL_CODEX_BASE_URL};
|
||||
use serde_json::{Map, json};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::oneshot,
|
||||
time::timeout,
|
||||
};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
@@ -225,6 +231,78 @@ fn diagnostic_context(request_id: &str, account_id: &str) -> AttemptContext {
|
||||
)
|
||||
}
|
||||
|
||||
async fn paused_chunked_sse_server(
|
||||
first_chunk: String,
|
||||
second_chunk: String,
|
||||
) -> (
|
||||
String,
|
||||
oneshot::Sender<()>,
|
||||
oneshot::Receiver<()>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind chunked SSE listener");
|
||||
let base_url = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("listener address")
|
||||
);
|
||||
let (release_sender, release_receiver) = oneshot::channel();
|
||||
let (first_chunk_sender, first_chunk_sent) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept chunked SSE request");
|
||||
read_http_request(&mut stream).await;
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.expect("write chunked SSE headers");
|
||||
write_http_chunk(&mut stream, &first_chunk).await;
|
||||
let _ = first_chunk_sender.send(());
|
||||
let _ = release_receiver.await;
|
||||
if !second_chunk.is_empty() {
|
||||
write_http_chunk(&mut stream, &second_chunk).await;
|
||||
}
|
||||
stream
|
||||
.write_all(b"0\r\n\r\n")
|
||||
.await
|
||||
.expect("terminate chunked SSE response");
|
||||
});
|
||||
(base_url, release_sender, first_chunk_sent, server)
|
||||
}
|
||||
|
||||
async fn read_http_request(stream: &mut TcpStream) {
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer).await.expect("read HTTP request");
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_http_chunk(stream: &mut TcpStream, body: &str) {
|
||||
stream
|
||||
.write_all(format!("{:X}\r\n", body.len()).as_bytes())
|
||||
.await
|
||||
.expect("write HTTP chunk size");
|
||||
stream
|
||||
.write_all(body.as_bytes())
|
||||
.await
|
||||
.expect("write HTTP chunk body");
|
||||
stream
|
||||
.write_all(b"\r\n")
|
||||
.await
|
||||
.expect("terminate HTTP chunk");
|
||||
stream.flush().await.expect("flush HTTP chunk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_provider_rejects_a_foreign_provider_candidate_before_account_selection() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -449,6 +527,355 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
|
||||
assert_eq!(affinity.binding_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_before_semantic_output_is_atomic_and_persists_cooldown() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_atomic_failure";
|
||||
create_account(&store, account_id).await;
|
||||
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_atomic_failure\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n",
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"status_code\":429,\"retry_after_seconds\":17,\"response\":{\"id\":\"resp_atomic_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"atomic upstream failure\"}}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_atomic_failure", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let mut visible_before_error = 0;
|
||||
let mut failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("response.failed must produce a typed failure"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(visible_before_error, 0);
|
||||
assert_eq!(failure.kind(), ProviderErrorKind::RateLimited);
|
||||
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
|
||||
assert_eq!(failure.upstream_status(), Some(429));
|
||||
assert!(failure.replay_is_safe());
|
||||
let events = failure.take_atomic_client_events();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event()?.event_type())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["response.created", "response.failed"]
|
||||
);
|
||||
let account = store.account(account_id).expect("rate-limited account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Cooldown);
|
||||
assert!(
|
||||
account
|
||||
.cooldown_until()
|
||||
.is_some_and(|until| until > SystemTime::now())
|
||||
);
|
||||
let _ = release.send(());
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_request_should_forward_created_before_later_failed_chunk() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_first_event_latency").await;
|
||||
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_first_event\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_first_event\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"later failure\"}}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_first_event_latency", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
|
||||
let first_event = loop {
|
||||
let next = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("first chunk must not wait for the withheld failure")
|
||||
.expect("provider stream must stay open")
|
||||
.expect("provider event");
|
||||
if next.has_client_event() {
|
||||
break next;
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
first_event.wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.created")
|
||||
);
|
||||
|
||||
release.send(()).expect("release second upstream chunk");
|
||||
let mut later_wire_types = Vec::new();
|
||||
let failure = loop {
|
||||
let next = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("later failure must arrive after release")
|
||||
.expect("provider stream must return typed failure");
|
||||
match next {
|
||||
Ok(event) => {
|
||||
if let Some(event_type) = event.wire_event().and_then(|wire| wire.event_type()) {
|
||||
later_wire_types.push(event_type.to_owned());
|
||||
}
|
||||
}
|
||||
Err(error) => break error,
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(later_wire_types, vec!["response.failed"]);
|
||||
assert!(!failure.replay_is_safe());
|
||||
assert!(!failure.has_atomic_client_events());
|
||||
server.await.expect("chunked SSE server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_should_hold_created_until_semantic_output() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_continuation_prefetch").await;
|
||||
let (base_url, release, first_chunk_sent, server) = paused_chunked_sse_server(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_continuation\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
concat!(
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\"}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_continuation\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_continuation_prefetch", CancellationToken::new())
|
||||
.with_continuation_attempt(ContinuationAttempt::Native),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
|
||||
let mut first_chunk_sent = Box::pin(first_chunk_sent);
|
||||
loop {
|
||||
tokio::select! {
|
||||
sent = &mut first_chunk_sent => {
|
||||
sent.expect("first upstream chunk");
|
||||
break;
|
||||
}
|
||||
next = stream.next() => {
|
||||
let event = next
|
||||
.expect("provider stream must stay open")
|
||||
.expect("provider event");
|
||||
assert!(!event.has_client_event(), "created was exposed before the first chunk barrier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let blocked = timeout(Duration::from_millis(100), async {
|
||||
loop {
|
||||
let next = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("provider stream must stay open")
|
||||
.expect("provider event");
|
||||
if next.has_client_event() {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(blocked.is_err());
|
||||
|
||||
release.send(()).expect("release semantic output chunk");
|
||||
let first_event = loop {
|
||||
let next = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("semantic output must release the prefetched batch")
|
||||
.expect("provider stream must stay open")
|
||||
.expect("provider event");
|
||||
if next.has_client_event() {
|
||||
break next;
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
first_event.wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.created")
|
||||
);
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("continuation response must complete");
|
||||
}
|
||||
server.await.expect("chunked SSE server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_bare_atomic_failure").await;
|
||||
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
|
||||
concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_bare_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"bare failure\"}}}\n\n"
|
||||
)
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_bare_atomic_failure", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let mut visible_before_error = 0;
|
||||
let mut failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("bare response.failed must produce a typed failure"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(visible_before_error, 0);
|
||||
assert!(failure.replay_is_safe());
|
||||
let events = failure.take_atomic_client_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.failed")
|
||||
);
|
||||
assert!(events[0]
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.any(|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "resp_bare_failure")));
|
||||
let _ = release.send(());
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_prefetch_over_64_kib_without_a_boundary_should_fail_as_protocol_error() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_prefetch_limit").await;
|
||||
let padding = "x".repeat(64 * 1024);
|
||||
let body = format!(
|
||||
"event: response.created\ndata: {}\n\n",
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_prefetch_limit",
|
||||
"model": "gpt-5.4",
|
||||
"status": "in_progress",
|
||||
"padding": padding,
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(body.len() > 64 * 1024);
|
||||
let (base_url, release, _first_chunk_sent, server) =
|
||||
paused_chunked_sse_server(body, String::new()).await;
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_prefetch_limit", CancellationToken::new())
|
||||
.with_continuation_attempt(ContinuationAttempt::Native),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let mut visible_before_error = 0;
|
||||
let failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("oversized prefetch must fail"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(visible_before_error, 0);
|
||||
assert_eq!(failure.kind(), ProviderErrorKind::Protocol);
|
||||
assert!(!failure.has_atomic_client_events());
|
||||
let _ = release.send(());
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_after_semantic_output_is_exposed_and_not_replay_safe() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_semantic_failure").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_string(concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_semantic_failure\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\"}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n",
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_semantic_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"failure after output\"}}}\n\n"
|
||||
)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_semantic_failure", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let mut wire_types = Vec::new();
|
||||
let failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => {
|
||||
if let Some(event_type) = event.wire_event().and_then(|wire| wire.event_type()) {
|
||||
wire_types.push(event_type.to_owned());
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("response.failed must produce a typed failure"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
wire_types,
|
||||
vec![
|
||||
"response.created",
|
||||
"response.content_part.added",
|
||||
"response.output_text.delta",
|
||||
"response.failed"
|
||||
]
|
||||
);
|
||||
assert!(!failure.replay_is_safe());
|
||||
assert!(!failure.has_atomic_client_events());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_account_diagnostic_uses_upstream_without_persisting_account_state() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -528,6 +528,40 @@ fn raw_sse_failure_should_keep_the_original_frame_before_reporting_the_typed_fai
|
||||
assert!(!format!("{failure:?}").contains("raw failure marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_response_failed_should_project_started_identity_with_the_fallback_model() {
|
||||
let raw = concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_bare_failed\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"bare failure\"}}}\n\n",
|
||||
);
|
||||
|
||||
let failure = CodexCanonicalDecoder::new("fallback-model")
|
||||
.push(raw.as_bytes())
|
||||
.expect_err("bare response.failed remains a typed lifecycle failure");
|
||||
|
||||
assert!(matches!(
|
||||
canonical_facts(failure.events()).as_slice(),
|
||||
[GatewayEvent::Started(metadata)]
|
||||
if metadata.response_id() == "resp_bare_failed"
|
||||
&& metadata.model() == "fallback-model"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_failed_without_identity_should_keep_the_upstream_typed_failure() {
|
||||
let raw = concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"missing identity\"}}}\n\n",
|
||||
);
|
||||
|
||||
let failure = CodexCanonicalDecoder::new("fallback")
|
||||
.push(raw.as_bytes())
|
||||
.expect_err("missing identity must not replace the upstream lifecycle failure");
|
||||
|
||||
assert!(matches!(failure.error(), CodexCanonicalError::Upstream(_)));
|
||||
assert!(canonical_facts(failure.events()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_same_chunk_output_before_typed_failure() {
|
||||
let marker = "same-chunk-secret-marker";
|
||||
|
||||
@@ -143,3 +143,19 @@ fn structured_expiry_code_classifies_without_an_auth_status() {
|
||||
CodexFailureCategory::CredentialExpired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_quota_signal_takes_precedence_over_http_429() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
429,
|
||||
r#"{"error":{"code":"insufficient_quota","message":"rate limit reached"}}"#
|
||||
),
|
||||
CodexFailureCategory::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_http_429_remains_a_temporary_rate_limit() {
|
||||
assert_eq!(classify(429, ""), CodexFailureCategory::RateLimited);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ use gateway_core::engine::credential::{
|
||||
AccountSelectionContext, AccountSelector, ProviderAccount, ProviderAccountId,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
|
||||
ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
|
||||
ProviderScopedCooldown,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
|
||||
use super::catalog::{GrokCatalogScope, GrokCredentialCatalogCache, GrokCredentialQuotaService};
|
||||
use super::repository::GrokCredentialRepository;
|
||||
@@ -28,6 +29,22 @@ 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);
|
||||
const PAYMENT_REQUIRED_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const MODEL_ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(5 * 60);
|
||||
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const MAX_MODEL_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RuntimeCooldownScope {
|
||||
Account,
|
||||
Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct RuntimeCooldown {
|
||||
until: SystemTime,
|
||||
scope: RuntimeCooldownScope,
|
||||
}
|
||||
|
||||
/// 仅经 Core account port、TTL catalog cache 和 Redis lease 选择一个 OAuth session。
|
||||
pub struct GrokAccountSessionSelector {
|
||||
@@ -124,7 +141,8 @@ impl GrokAccountSessionSelector {
|
||||
let runtime_cooldowns = if diagnostic {
|
||||
std::collections::BTreeMap::new()
|
||||
} else {
|
||||
self.runtime_cooldowns(&catalog_eligible).await
|
||||
self.runtime_cooldowns(&catalog_eligible, request.upstream_model())
|
||||
.await
|
||||
};
|
||||
let mut candidates = catalog_eligible
|
||||
.into_iter()
|
||||
@@ -234,16 +252,26 @@ impl GrokAccountSessionSelector {
|
||||
return Err(GrokSessionSelectorError::CapacityUnavailable { retry_after });
|
||||
}
|
||||
// 钉死账号时只看该账号,否则取最早退出 cooldown 的候选。
|
||||
let cooled_until = match request.required_account() {
|
||||
let cooled = match request.required_account() {
|
||||
Some(required) => runtime_cooldowns.get(required).copied(),
|
||||
None => runtime_cooldowns.values().copied().min(),
|
||||
None => runtime_cooldowns
|
||||
.values()
|
||||
.copied()
|
||||
.min_by_key(|item| item.until),
|
||||
};
|
||||
if let Some(until) = cooled_until {
|
||||
return Err(GrokSessionSelectorError::AccountCoolingDown {
|
||||
retry_after: until
|
||||
.duration_since(SystemTime::now())
|
||||
.ok()
|
||||
.filter(|remaining| !remaining.is_zero()),
|
||||
if let Some(cooled) = cooled {
|
||||
let retry_after = cooled
|
||||
.until
|
||||
.duration_since(SystemTime::now())
|
||||
.ok()
|
||||
.filter(|remaining| !remaining.is_zero());
|
||||
return Err(match cooled.scope {
|
||||
RuntimeCooldownScope::Account => {
|
||||
GrokSessionSelectorError::AccountCoolingDown { retry_after }
|
||||
}
|
||||
RuntimeCooldownScope::Model => {
|
||||
GrokSessionSelectorError::ModelCoolingDown { retry_after }
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(GrokSessionSelectorError::NoEligibleSession)
|
||||
@@ -252,28 +280,56 @@ impl GrokAccountSessionSelector {
|
||||
async fn runtime_cooldowns(
|
||||
&self,
|
||||
accounts: &[ProviderAccount],
|
||||
) -> std::collections::BTreeMap<ProviderAccountId, SystemTime> {
|
||||
upstream_model: &UpstreamModelId,
|
||||
) -> std::collections::BTreeMap<ProviderAccountId, RuntimeCooldown> {
|
||||
let now = SystemTime::now();
|
||||
// 冷却状态并发批量读取,避免逐账号串行往返。
|
||||
let reads = futures::future::join_all(
|
||||
let model_scope = ProviderCooldownScope::upstream_model(upstream_model.clone());
|
||||
// 账号级和当前模型级冷却都并发批量读取,避免逐账号串行往返。
|
||||
let account_reads = futures::future::join_all(
|
||||
accounts
|
||||
.iter()
|
||||
.map(|account| self.cooldowns.read(account.id())),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let model_reads = futures::future::join_all(
|
||||
accounts
|
||||
.iter()
|
||||
.map(|account| self.cooldowns.read_scoped(account.id(), &model_scope)),
|
||||
);
|
||||
let (account_reads, model_reads) = futures::join!(account_reads, model_reads);
|
||||
let mut cooled = std::collections::BTreeMap::new();
|
||||
for (account, read) in accounts.iter().zip(reads) {
|
||||
let Ok(Some(cooldown)) = read else {
|
||||
continue;
|
||||
};
|
||||
if cooldown.credential_revision() != account.revision() {
|
||||
if cooldown.credential_revision() < account.revision() {
|
||||
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
|
||||
for ((account, account_read), model_read) in
|
||||
accounts.iter().zip(account_reads).zip(model_reads)
|
||||
{
|
||||
if let Ok(Some(cooldown)) = account_read {
|
||||
if cooldown.credential_revision() != account.revision() {
|
||||
if cooldown.credential_revision() < account.revision() {
|
||||
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
|
||||
}
|
||||
} else if cooldown.until() > now {
|
||||
insert_runtime_cooldown(
|
||||
&mut cooled,
|
||||
account.id(),
|
||||
cooldown.until(),
|
||||
RuntimeCooldownScope::Account,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if cooldown.until() > now {
|
||||
cooled.insert(account.id().clone(), cooldown.until());
|
||||
if let Ok(Some(cooldown)) = model_read {
|
||||
if cooldown.credential_revision() != account.revision() {
|
||||
if cooldown.credential_revision() < account.revision() {
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.clear_scoped(account.id(), &model_scope, account.revision())
|
||||
.await;
|
||||
}
|
||||
} else if cooldown.until() > now {
|
||||
insert_runtime_cooldown(
|
||||
&mut cooled,
|
||||
account.id(),
|
||||
cooldown.until(),
|
||||
RuntimeCooldownScope::Model,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
cooled
|
||||
@@ -292,6 +348,26 @@ impl GrokAccountSessionSelector {
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn record_model_runtime_cooldown(
|
||||
&self,
|
||||
session: &SelectedGrokSession,
|
||||
upstream_model: UpstreamModelId,
|
||||
duration: Duration,
|
||||
) {
|
||||
let cooldown_until = SystemTime::now()
|
||||
.checked_add(duration)
|
||||
.unwrap_or_else(SystemTime::now);
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.put_scoped_if_later(ProviderScopedCooldown::new(
|
||||
session.account_id().clone(),
|
||||
session.credential_revision(),
|
||||
ProviderCooldownScope::upstream_model(upstream_model),
|
||||
cooldown_until,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrokAccountSessionSelector {
|
||||
@@ -333,6 +409,43 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
|
||||
"upstream_quota_exhausted",
|
||||
None,
|
||||
),
|
||||
GrokCredentialFailure::FreeQuotaExhausted => (
|
||||
GrokCredentialAvailability::QuotaExhausted,
|
||||
"upstream_free_quota_exhausted",
|
||||
None,
|
||||
),
|
||||
GrokCredentialFailure::PaymentRequired { retry_after } => {
|
||||
let retry_after = bounded_cooldown(
|
||||
retry_after,
|
||||
PAYMENT_REQUIRED_COOLDOWN,
|
||||
MAX_RATE_LIMIT_COOLDOWN,
|
||||
);
|
||||
self.record_runtime_cooldown(session, retry_after).await;
|
||||
return;
|
||||
}
|
||||
GrokCredentialFailure::ModelQuotaExhausted {
|
||||
upstream_model,
|
||||
retry_after,
|
||||
} => {
|
||||
let retry_after =
|
||||
bounded_cooldown(retry_after, MODEL_QUOTA_COOLDOWN, MAX_MODEL_COOLDOWN);
|
||||
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
GrokCredentialFailure::ModelAccessDenied {
|
||||
upstream_model,
|
||||
retry_after,
|
||||
} => {
|
||||
let retry_after = bounded_cooldown(
|
||||
retry_after,
|
||||
MODEL_ACCESS_DENIED_COOLDOWN,
|
||||
MAX_MODEL_COOLDOWN,
|
||||
);
|
||||
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
GrokCredentialFailure::RateLimited { retry_after } => {
|
||||
let retry_after = retry_after
|
||||
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
|
||||
@@ -414,3 +527,29 @@ fn minimum_retry_after(left: Option<Duration>, right: Option<Duration>) -> Optio
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_cooldown(
|
||||
retry_after: Option<Duration>,
|
||||
default: Duration,
|
||||
maximum: Duration,
|
||||
) -> Duration {
|
||||
retry_after
|
||||
.filter(|duration| !duration.is_zero())
|
||||
.unwrap_or(default)
|
||||
.min(maximum)
|
||||
}
|
||||
|
||||
fn insert_runtime_cooldown(
|
||||
cooldowns: &mut std::collections::BTreeMap<ProviderAccountId, RuntimeCooldown>,
|
||||
account_id: &ProviderAccountId,
|
||||
until: SystemTime,
|
||||
scope: RuntimeCooldownScope,
|
||||
) {
|
||||
let replacement = RuntimeCooldown { until, scope };
|
||||
if cooldowns
|
||||
.get(account_id)
|
||||
.is_none_or(|current| current.until < until)
|
||||
{
|
||||
cooldowns.insert(account_id.clone(), replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +927,7 @@ async fn start_grok_inference(
|
||||
transport: &dyn GrokInferenceTransport,
|
||||
credential_recovery: &dyn GrokCredentialRecovery,
|
||||
request: GrokInferenceRequest,
|
||||
upstream_model: &UpstreamModelId,
|
||||
context: &AttemptContext,
|
||||
session: &SelectedGrokSession,
|
||||
) -> Result<AcceptedGrokInference, GrokInferenceStartFailure> {
|
||||
@@ -965,6 +966,7 @@ async fn start_grok_inference(
|
||||
InferenceBoundary::Response(Ok(response)) => response,
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
let observation = xai_error_observation(&error).ok();
|
||||
let credential_failure = transport_credential_failure(&error, upstream_model);
|
||||
let error =
|
||||
map_continuation_failure(context, map_transport_error_for_context(error, context));
|
||||
let error = recover_or_record_failure(
|
||||
@@ -972,6 +974,7 @@ async fn start_grok_inference(
|
||||
credential_recovery,
|
||||
session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
@@ -993,6 +996,7 @@ async fn next_grok_chunk(
|
||||
body: &mut GrokInferenceChunkStream,
|
||||
selector: &dyn GrokSessionSelector,
|
||||
session: &SelectedGrokSession,
|
||||
upstream_model: &UpstreamModelId,
|
||||
context: &AttemptContext,
|
||||
) -> Result<Option<bytes::Bytes>, ProviderError> {
|
||||
let Some(stream_deadline) = remaining(context.deadline()) else {
|
||||
@@ -1014,10 +1018,12 @@ async fn next_grok_chunk(
|
||||
)),
|
||||
chunk = body.next() => match chunk {
|
||||
Some(Ok(chunk)) => Ok(Some(chunk)),
|
||||
Some(Err(error)) => {
|
||||
let error = map_stream_error(error);
|
||||
Err(record_stream_failure(selector, session, error).await)
|
||||
}
|
||||
Some(Err(error)) => Err(map_and_record_stream_transport_failure(
|
||||
selector,
|
||||
session,
|
||||
error,
|
||||
upstream_model,
|
||||
).await),
|
||||
None => Ok(None),
|
||||
},
|
||||
}
|
||||
@@ -1067,6 +1073,7 @@ fn cold_compaction_http_sse_stream(
|
||||
transport.as_ref(),
|
||||
credential_recovery.as_ref(),
|
||||
inference_request,
|
||||
&upstream_model,
|
||||
&context,
|
||||
&session,
|
||||
)
|
||||
@@ -1092,6 +1099,7 @@ fn cold_compaction_http_sse_stream(
|
||||
&mut body,
|
||||
selector.as_ref(),
|
||||
&session,
|
||||
&upstream_model,
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
@@ -1287,6 +1295,7 @@ fn cold_http_sse_stream(
|
||||
InferenceBoundary::Response(Ok(response)) => response,
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
let observation = xai_error_observation(&error)?;
|
||||
let credential_failure = transport_credential_failure(&error, &upstream_model);
|
||||
let error =
|
||||
map_continuation_failure(&context, map_transport_error_for_context(error, &context));
|
||||
let error = recover_or_record_failure(
|
||||
@@ -1294,6 +1303,7 @@ fn cold_http_sse_stream(
|
||||
credential_recovery.as_ref(),
|
||||
&session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
@@ -1328,10 +1338,12 @@ fn cold_http_sse_stream(
|
||||
)),
|
||||
chunk = body.next() => match chunk {
|
||||
Some(Ok(chunk)) => Ok(Some(chunk)),
|
||||
Some(Err(error)) => {
|
||||
let error = map_stream_error(error);
|
||||
Err(record_stream_failure(selector.as_ref(), &session, error).await)
|
||||
},
|
||||
Some(Err(error)) => Err(map_and_record_stream_transport_failure(
|
||||
selector.as_ref(),
|
||||
&session,
|
||||
error,
|
||||
&upstream_model,
|
||||
).await),
|
||||
None => Ok(None),
|
||||
},
|
||||
}?;
|
||||
@@ -1495,6 +1507,18 @@ async fn record_failure(
|
||||
error
|
||||
}
|
||||
|
||||
async fn record_credential_failure(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
session: &SelectedGrokSession,
|
||||
error: ProviderError,
|
||||
failure: GrokCredentialFailure,
|
||||
) -> ProviderError {
|
||||
if session.allows_account_state_mutation() {
|
||||
selector.record_failure(session, failure).await;
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
async fn record_stream_failure(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
session: &SelectedGrokSession,
|
||||
@@ -1524,6 +1548,7 @@ async fn recover_or_record_failure(
|
||||
recovery: &dyn GrokCredentialRecovery,
|
||||
session: &SelectedGrokSession,
|
||||
error: ProviderError,
|
||||
credential_failure: Option<GrokCredentialFailure>,
|
||||
recovery_attempted: bool,
|
||||
) -> ProviderError {
|
||||
if !session.allows_account_state_mutation() {
|
||||
@@ -1536,12 +1561,68 @@ async fn recover_or_record_failure(
|
||||
{
|
||||
GrokCredentialRecoveryOutcome::Recovered => error.with_same_account_retry(),
|
||||
GrokCredentialRecoveryOutcome::Rejected => error,
|
||||
GrokCredentialRecoveryOutcome::Unavailable => {
|
||||
record_failure(selector, session, error).await
|
||||
}
|
||||
GrokCredentialRecoveryOutcome::Unavailable => match credential_failure {
|
||||
Some(failure) => record_credential_failure(selector, session, error, failure).await,
|
||||
None => error,
|
||||
},
|
||||
};
|
||||
}
|
||||
record_failure(selector, session, error).await
|
||||
match credential_failure {
|
||||
Some(failure) => record_credential_failure(selector, session, error, failure).await,
|
||||
None => error,
|
||||
}
|
||||
}
|
||||
|
||||
fn transport_credential_failure(
|
||||
error: &GrokInferenceTransportError,
|
||||
upstream_model: &UpstreamModelId,
|
||||
) -> Option<GrokCredentialFailure> {
|
||||
match error.kind() {
|
||||
GrokInferenceTransportErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized),
|
||||
GrokInferenceTransportErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited {
|
||||
retry_after: error.retry_after(),
|
||||
}),
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted => {
|
||||
Some(GrokCredentialFailure::QuotaExhausted)
|
||||
}
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted => {
|
||||
Some(GrokCredentialFailure::FreeQuotaExhausted)
|
||||
}
|
||||
GrokInferenceTransportErrorKind::PaymentRequired => {
|
||||
Some(GrokCredentialFailure::PaymentRequired {
|
||||
retry_after: error.retry_after(),
|
||||
})
|
||||
}
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted => {
|
||||
Some(GrokCredentialFailure::ModelQuotaExhausted {
|
||||
upstream_model: upstream_model.clone(),
|
||||
retry_after: error.retry_after(),
|
||||
})
|
||||
}
|
||||
GrokInferenceTransportErrorKind::ModelAccessDenied => {
|
||||
Some(GrokCredentialFailure::ModelAccessDenied {
|
||||
upstream_model: upstream_model.clone(),
|
||||
retry_after: error.retry_after(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn map_and_record_stream_transport_failure(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
session: &SelectedGrokSession,
|
||||
error: GrokInferenceTransportError,
|
||||
upstream_model: &UpstreamModelId,
|
||||
) -> ProviderError {
|
||||
let request_scoped = error.kind() == GrokInferenceTransportErrorKind::SafetyRejected;
|
||||
let credential_failure = transport_credential_failure(&error, upstream_model);
|
||||
let error = map_stream_error(error);
|
||||
match credential_failure {
|
||||
Some(failure) => record_credential_failure(selector, session, error, failure).await,
|
||||
None if request_scoped => error,
|
||||
None => record_stream_failure(selector, session, error).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_continuation_failure(context: &AttemptContext, error: ProviderError) -> ProviderError {
|
||||
@@ -1628,6 +1709,11 @@ fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
|
||||
cooling_down_message(retry_after),
|
||||
"account_cooling_down",
|
||||
),
|
||||
GrokSessionSelectorError::ModelCoolingDown { retry_after } => (
|
||||
retry_after,
|
||||
model_cooling_down_message(retry_after),
|
||||
"model_cooling_down",
|
||||
),
|
||||
GrokSessionSelectorError::CapacityUnavailable { retry_after } => (
|
||||
retry_after,
|
||||
"account is at its concurrency or request-interval limit".to_owned(),
|
||||
@@ -1665,10 +1751,20 @@ fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
|
||||
fn cooling_down_message(retry_after: Option<Duration>) -> String {
|
||||
match retry_after {
|
||||
Some(retry_after) => format!(
|
||||
"account is cooling down after an interrupted upstream stream; retry in {}s",
|
||||
"account is cooling down after an upstream failure; retry in {}s",
|
||||
retry_after.as_secs().saturating_add(1)
|
||||
),
|
||||
None => "account is cooling down after an interrupted upstream stream".to_owned(),
|
||||
None => "account is cooling down after an upstream failure".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_cooling_down_message(retry_after: Option<Duration>) -> String {
|
||||
match retry_after {
|
||||
Some(retry_after) => format!(
|
||||
"all eligible accounts are cooling down for this model; retry in {}s",
|
||||
retry_after.as_secs().saturating_add(1)
|
||||
),
|
||||
None => "all eligible accounts are cooling down for this model".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1690,13 +1786,19 @@ fn map_transport_error_with_state(
|
||||
forced_send_state: Option<UpstreamSendState>,
|
||||
allow_explicit_replay: bool,
|
||||
) -> ProviderError {
|
||||
let kind = match error.kind() {
|
||||
let transport_kind = error.kind();
|
||||
let kind = match transport_kind {
|
||||
GrokInferenceTransportErrorKind::InvalidRequest => ProviderErrorKind::InvalidRequest,
|
||||
GrokInferenceTransportErrorKind::Unsupported => ProviderErrorKind::Unsupported,
|
||||
GrokInferenceTransportErrorKind::Unauthorized => ProviderErrorKind::Unauthorized,
|
||||
GrokInferenceTransportErrorKind::PermissionDenied => ProviderErrorKind::PermissionDenied,
|
||||
GrokInferenceTransportErrorKind::PermissionDenied
|
||||
| GrokInferenceTransportErrorKind::ModelAccessDenied
|
||||
| GrokInferenceTransportErrorKind::PaymentRequired
|
||||
| GrokInferenceTransportErrorKind::SafetyRejected => ProviderErrorKind::PermissionDenied,
|
||||
GrokInferenceTransportErrorKind::RateLimited => ProviderErrorKind::RateLimited,
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted => ProviderErrorKind::QuotaExhausted,
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
| GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
| GrokInferenceTransportErrorKind::ModelQuotaExhausted => ProviderErrorKind::QuotaExhausted,
|
||||
GrokInferenceTransportErrorKind::Timeout => ProviderErrorKind::Timeout,
|
||||
GrokInferenceTransportErrorKind::Transport => ProviderErrorKind::Transport,
|
||||
GrokInferenceTransportErrorKind::Protocol => ProviderErrorKind::Protocol,
|
||||
@@ -1711,7 +1813,7 @@ fn map_transport_error_with_state(
|
||||
mapped = mapped.with_status(status);
|
||||
if allow_explicit_replay
|
||||
&& forced_send_state.is_none()
|
||||
&& explicit_rejection_is_replay_safe(kind, status)
|
||||
&& explicit_rejection_is_replay_safe(transport_kind, status)
|
||||
{
|
||||
mapped = mapped.with_replay_safe();
|
||||
}
|
||||
@@ -1725,6 +1827,9 @@ fn map_transport_error_with_state(
|
||||
if let Some(code) = error.upstream_code().cloned() {
|
||||
mapped = mapped.with_upstream_code(code);
|
||||
}
|
||||
if let Some(detail) = error.client_visible_upstream_error().cloned() {
|
||||
mapped = mapped.with_client_visible_upstream_error(detail);
|
||||
}
|
||||
if error.requires_credential_recovery() {
|
||||
mapped = mapped.with_credential_recovery().with_replay_safe();
|
||||
}
|
||||
@@ -1734,14 +1839,25 @@ fn map_transport_error_with_state(
|
||||
mapped
|
||||
}
|
||||
|
||||
fn explicit_rejection_is_replay_safe(kind: ProviderErrorKind, status: u16) -> bool {
|
||||
fn explicit_rejection_is_replay_safe(kind: GrokInferenceTransportErrorKind, status: u16) -> bool {
|
||||
matches!(
|
||||
(kind, status),
|
||||
(ProviderErrorKind::Unauthorized, 401)
|
||||
| (ProviderErrorKind::QuotaExhausted, 402)
|
||||
| (ProviderErrorKind::QuotaExhausted, 403)
|
||||
| (ProviderErrorKind::QuotaExhausted, 429)
|
||||
| (ProviderErrorKind::RateLimited, 429)
|
||||
(GrokInferenceTransportErrorKind::Unauthorized, 401)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (GrokInferenceTransportErrorKind::PaymentRequired, 402)
|
||||
| (GrokInferenceTransportErrorKind::ModelAccessDenied, 403)
|
||||
| (GrokInferenceTransportErrorKind::RateLimited, 429)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::request::{GrokResponseTransform, GrokResponsesRequest};
|
||||
use super::scrub_account_fingerprints;
|
||||
|
||||
const CONTENTS_PER_OUTPUT: u32 = 1_024;
|
||||
const LONG_CONTEXT_THRESHOLD: u64 = 200_000;
|
||||
@@ -820,53 +821,6 @@ fn upstream_error_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
||||
.and_then(Value::as_str)
|
||||
}
|
||||
|
||||
const UUID_TEXT_LEN: usize = 36;
|
||||
|
||||
/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格,
|
||||
/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。
|
||||
fn scrub_account_fingerprints(message: &str) -> String {
|
||||
let bytes = message.as_bytes();
|
||||
let mut scrubbed = String::with_capacity(message.len());
|
||||
let mut index = 0;
|
||||
while index < message.len() {
|
||||
if uuid_at(bytes, index) {
|
||||
scrubbed.push_str("[redacted]");
|
||||
index += UUID_TEXT_LEN;
|
||||
continue;
|
||||
}
|
||||
let character = message[index..].chars().next().expect("char boundary");
|
||||
scrubbed.push(if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
});
|
||||
index += character.len_utf8();
|
||||
}
|
||||
scrubbed
|
||||
}
|
||||
|
||||
fn uuid_at(bytes: &[u8], index: usize) -> bool {
|
||||
let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else {
|
||||
return false;
|
||||
};
|
||||
if index > 0 && bytes[index - 1].is_ascii_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
if bytes
|
||||
.get(index + UUID_TEXT_LEN)
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
candidate.iter().enumerate().all(|(offset, byte)| {
|
||||
if matches!(offset, 8 | 13 | 18 | 23) {
|
||||
*byte == b'-'
|
||||
} else {
|
||||
byte.is_ascii_hexdigit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn protocol_error(_error: impl std::fmt::Debug) -> ProviderError {
|
||||
protocol_error_marker()
|
||||
}
|
||||
|
||||
@@ -54,3 +54,50 @@ pub use transport::{
|
||||
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
|
||||
GrokInferenceTransportMetrics,
|
||||
};
|
||||
|
||||
const UUID_TEXT_LEN: usize = 36;
|
||||
|
||||
/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格,
|
||||
/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。
|
||||
fn scrub_account_fingerprints(message: &str) -> String {
|
||||
let bytes = message.as_bytes();
|
||||
let mut scrubbed = String::with_capacity(message.len());
|
||||
let mut index = 0;
|
||||
while index < message.len() {
|
||||
if uuid_at(bytes, index) {
|
||||
scrubbed.push_str("[redacted]");
|
||||
index += UUID_TEXT_LEN;
|
||||
continue;
|
||||
}
|
||||
let character = message[index..].chars().next().expect("char boundary");
|
||||
scrubbed.push(if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
});
|
||||
index += character.len_utf8();
|
||||
}
|
||||
scrubbed
|
||||
}
|
||||
|
||||
fn uuid_at(bytes: &[u8], index: usize) -> bool {
|
||||
let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else {
|
||||
return false;
|
||||
};
|
||||
if index > 0 && bytes[index - 1].is_ascii_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
if bytes
|
||||
.get(index + UUID_TEXT_LEN)
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
candidate.iter().enumerate().all(|(offset, byte)| {
|
||||
if matches!(offset, 8 | 13 | 18 | 23) {
|
||||
*byte == b'-'
|
||||
} else {
|
||||
byte.is_ascii_hexdigit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ use crate::{
|
||||
OAuthHttpResponse, OAuthHttpTransport, TransportFailure, TransportFailureKind, TransportFuture,
|
||||
};
|
||||
use gateway_core::engine::UpstreamSendState;
|
||||
use gateway_core::error::SafeUpstreamValue;
|
||||
use gateway_core::error::{ClientVisibleUpstreamError, SafeUpstreamValue};
|
||||
use gateway_core::event::UpstreamHttpVersion;
|
||||
|
||||
use super::scrub_account_fingerprints;
|
||||
|
||||
pub(crate) const OFFICIAL_OAUTH_HOST: &str = "auth.x.ai";
|
||||
const OFFICIAL_INFERENCE_HOST: &str = "cli-chat-proxy.grok.com";
|
||||
const OFFICIAL_INFERENCE_PATH: &str = "/v1/responses";
|
||||
@@ -990,29 +992,22 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
|
||||
Ok(BoundedBody::TooLarge) | Err(_) => Vec::new(),
|
||||
};
|
||||
let metadata = inference_error_metadata(&body);
|
||||
let credential_recovery_required = status == StatusCode::UNAUTHORIZED
|
||||
|| (status == StatusCode::FORBIDDEN && forbidden_requires_recovery(&metadata));
|
||||
let kind = match status {
|
||||
StatusCode::BAD_REQUEST
|
||||
| StatusCode::NOT_FOUND
|
||||
| StatusCode::CONFLICT
|
||||
| StatusCode::UNPROCESSABLE_ENTITY => GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
|
||||
StatusCode::PAYMENT_REQUIRED => GrokInferenceTransportErrorKind::QuotaExhausted,
|
||||
StatusCode::FORBIDDEN => classify_forbidden(&metadata),
|
||||
StatusCode::PAYMENT_REQUIRED => classify_payment_required(&metadata),
|
||||
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body),
|
||||
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
|
||||
GrokInferenceTransportErrorKind::Timeout
|
||||
}
|
||||
StatusCode::TOO_MANY_REQUESTS => {
|
||||
if quota_exhausted(&metadata) {
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
} else {
|
||||
GrokInferenceTransportErrorKind::RateLimited
|
||||
}
|
||||
}
|
||||
StatusCode::TOO_MANY_REQUESTS => classify_too_many_requests(&metadata),
|
||||
status if status.is_server_error() => GrokInferenceTransportErrorKind::Unavailable,
|
||||
_ => GrokInferenceTransportErrorKind::Protocol,
|
||||
};
|
||||
let credential_recovery_required = kind == GrokInferenceTransportErrorKind::Unauthorized;
|
||||
let mut error = GrokInferenceTransportError::new(kind, UpstreamSendState::Sent)
|
||||
.with_status(status_code)
|
||||
.with_response_facts(http_version, request_id)
|
||||
@@ -1022,6 +1017,15 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
|
||||
} else {
|
||||
metadata.code.as_deref().and_then(normalize_failure_code)
|
||||
};
|
||||
if let Some(message) = metadata.client_message.as_deref()
|
||||
&& let Ok(detail) = ClientVisibleUpstreamError::new(
|
||||
scrub_account_fingerprints(message),
|
||||
upstream_code.clone(),
|
||||
metadata.error_type.clone(),
|
||||
)
|
||||
{
|
||||
error = error.with_client_visible_upstream_error(detail);
|
||||
}
|
||||
if let Some(code) = upstream_code.and_then(|code| SafeUpstreamValue::new(code).ok()) {
|
||||
error = error.with_upstream_code(code);
|
||||
}
|
||||
@@ -1039,6 +1043,8 @@ struct InferenceErrorMetadata {
|
||||
code: Option<String>,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
// 仅结构化 JSON 的 message 可进入客户端协议;纯文本正文只参与内部分类。
|
||||
client_message: Option<String>,
|
||||
}
|
||||
|
||||
fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
|
||||
@@ -1053,6 +1059,9 @@ fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
|
||||
return InferenceErrorMetadata::default();
|
||||
};
|
||||
let nested = root.get("error").and_then(Value::as_object);
|
||||
let message = nested
|
||||
.and_then(|error| first_string(error, &["message", "error"]))
|
||||
.or_else(|| first_string(root, &["message", "error"]));
|
||||
InferenceErrorMetadata {
|
||||
code: nested
|
||||
.and_then(|error| first_string(error, &["code", "error_code"]))
|
||||
@@ -1060,9 +1069,8 @@ fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
|
||||
error_type: nested
|
||||
.and_then(|error| first_string(error, &["type", "error_type"]))
|
||||
.or_else(|| first_string(root, &["type", "error_type"])),
|
||||
message: nested
|
||||
.and_then(|error| first_string(error, &["message", "error"]))
|
||||
.or_else(|| first_string(root, &["message", "error"])),
|
||||
client_message: message.clone(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,22 +1083,53 @@ fn first_string(object: &serde_json::Map<String, Value>, fields: &[&str]) -> Opt
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn classify_forbidden(metadata: &InferenceErrorMetadata) -> GrokInferenceTransportErrorKind {
|
||||
let text = inference_metadata_text(metadata);
|
||||
if credential_rejected(&text) {
|
||||
GrokInferenceTransportErrorKind::Unauthorized
|
||||
} else if quota_exhausted(metadata) {
|
||||
fn classify_payment_required(metadata: &InferenceErrorMetadata) -> GrokInferenceTransportErrorKind {
|
||||
if model_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted
|
||||
} else if paid_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
} else if free_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
} else {
|
||||
GrokInferenceTransportErrorKind::PaymentRequired
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_forbidden(
|
||||
metadata: &InferenceErrorMetadata,
|
||||
body: &[u8],
|
||||
) -> GrokInferenceTransportErrorKind {
|
||||
let text = inference_metadata_text(metadata);
|
||||
let body_text = String::from_utf8_lossy(body).to_ascii_lowercase();
|
||||
if safety_rejected(&text) || safety_rejected(&body_text) {
|
||||
GrokInferenceTransportErrorKind::SafetyRejected
|
||||
} else if credential_rejected(&text) {
|
||||
GrokInferenceTransportErrorKind::Unauthorized
|
||||
} else if model_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted
|
||||
} else if paid_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
} else if free_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
} else if model_access_denied(metadata) {
|
||||
GrokInferenceTransportErrorKind::ModelAccessDenied
|
||||
} else {
|
||||
GrokInferenceTransportErrorKind::PermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
fn forbidden_requires_recovery(metadata: &InferenceErrorMetadata) -> bool {
|
||||
let text = inference_metadata_text(metadata);
|
||||
credential_rejected(&text)
|
||||
|| text.contains("access to the chat endpoint is denied")
|
||||
|| text.trim_matches([' ', '.', '!', '\t', '\r', '\n']) == "access denied"
|
||||
fn classify_too_many_requests(
|
||||
metadata: &InferenceErrorMetadata,
|
||||
) -> GrokInferenceTransportErrorKind {
|
||||
if model_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted
|
||||
} else if paid_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
} else if free_quota_exhausted(metadata) {
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
} else {
|
||||
GrokInferenceTransportErrorKind::RateLimited
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
|
||||
@@ -1100,7 +1139,17 @@ fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
|
||||
fn paid_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
|
||||
contains_any(
|
||||
&inference_metadata_text(metadata),
|
||||
&[
|
||||
"personal-team-blocked:spending-limit",
|
||||
"personal_team_blocked_spending_limit",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn free_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
|
||||
contains_any(
|
||||
&inference_metadata_text(metadata),
|
||||
&[
|
||||
@@ -1110,12 +1159,29 @@ fn quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
|
||||
"free_usage_exhausted",
|
||||
"used all the included free usage",
|
||||
"used all your free usage",
|
||||
"personal-team-blocked:spending-limit",
|
||||
"personal_team_blocked_spending_limit",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn model_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
|
||||
inference_metadata_text(metadata).contains("used all the included free usage for model")
|
||||
}
|
||||
|
||||
fn model_access_denied(metadata: &InferenceErrorMetadata) -> bool {
|
||||
metadata.message.as_deref().is_some_and(|message| {
|
||||
let message = message.to_ascii_lowercase();
|
||||
message.contains("access to the chat endpoint is denied")
|
||||
|| message.trim_matches([' ', '.', '!', '\t', '\r', '\n']) == "access denied"
|
||||
})
|
||||
}
|
||||
|
||||
fn safety_rejected(text: &str) -> bool {
|
||||
contains_any(
|
||||
text,
|
||||
&["content violates usage guidelines", "safety_check_type_"],
|
||||
)
|
||||
}
|
||||
|
||||
fn inference_metadata_text(metadata: &InferenceErrorMetadata) -> String {
|
||||
[
|
||||
metadata.code.as_deref().unwrap_or_default(),
|
||||
|
||||
@@ -300,8 +300,8 @@ pub type GrokSessionSelectorFuture<'a> = Pin<
|
||||
Box<dyn Future<Output = Result<SelectedGrokSession, GrokSessionSelectorError>> + Send + 'a>,
|
||||
>;
|
||||
|
||||
/// 凭据维度的上游失败,可安全落库为可用性反馈。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 已脱敏的上游失败反馈;selector 决定账号持久状态或细粒度运行时 cooldown。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GrokCredentialFailure {
|
||||
/// 选中的 OAuth token 被拒绝。
|
||||
Unauthorized,
|
||||
@@ -310,8 +310,25 @@ pub enum GrokCredentialFailure {
|
||||
/// 已解析并脱敏的延迟;不保留上游原始 header。
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 选中的 OAuth 账号配额耗尽。
|
||||
/// 选中的 OAuth 账号付费额度耗尽。
|
||||
QuotaExhausted,
|
||||
/// 选中的 OAuth 账号订阅级免费额度耗尽。
|
||||
FreeQuotaExhausted,
|
||||
/// HTTP 402 没有给出可证明账号额度耗尽的稳定信号。
|
||||
PaymentRequired {
|
||||
/// 已解析并脱敏的延迟;缺失时由 selector 使用短暂默认值。
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 选中账号只耗尽了指定模型的额度。
|
||||
ModelQuotaExhausted {
|
||||
upstream_model: UpstreamModelId,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 选中账号只缺少指定模型的访问权限。
|
||||
ModelAccessDenied {
|
||||
upstream_model: UpstreamModelId,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 成功的 SSE 响应在终止 Responses 事件前结束。
|
||||
StreamInterrupted,
|
||||
}
|
||||
@@ -325,7 +342,7 @@ pub trait GrokSessionSelector: Send + Sync {
|
||||
/// 执行一次选择,不做 Provider 内部回退。
|
||||
fn select(&self, request: GrokSessionSelection) -> GrokSessionSelectorFuture<'_>;
|
||||
|
||||
/// 持久化一条已分类的失败,不重试也不替换原错误。
|
||||
/// 记录一条已分类的失败反馈,不重试也不替换原错误。
|
||||
fn record_failure<'a>(
|
||||
&'a self,
|
||||
session: &'a SelectedGrokSession,
|
||||
@@ -361,6 +378,12 @@ pub enum GrokSessionSelectorError {
|
||||
/// 最早恢复的会话剩余冷却时长。
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 每个可用会话都只针对当前模型处于运行时冷却窗口内。
|
||||
#[error("Grok Build model is cooling down for the available accounts")]
|
||||
ModelCoolingDown {
|
||||
/// 最早恢复的账号+模型组合剩余冷却时长。
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 会话元数据或 Provider 持有的明文密钥非法。
|
||||
#[error("Grok Build session data is invalid")]
|
||||
InvalidSession,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use futures::Stream;
|
||||
use gateway_core::engine::UpstreamSendState;
|
||||
use gateway_core::error::SafeUpstreamValue;
|
||||
use gateway_core::error::{ClientVisibleUpstreamError, SafeUpstreamValue};
|
||||
use gateway_core::event::UpstreamHttpVersion;
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
@@ -278,8 +278,18 @@ pub enum GrokInferenceTransportErrorKind {
|
||||
PermissionDenied,
|
||||
/// 会话级或 Provider 级限流。
|
||||
RateLimited,
|
||||
/// 账号额度或配额耗尽。
|
||||
/// 账号付费额度或 spending limit 耗尽。
|
||||
QuotaExhausted,
|
||||
/// 账号订阅级免费额度耗尽。
|
||||
FreeQuotaExhausted,
|
||||
/// 当前账号只耗尽了指定模型的免费额度。
|
||||
ModelQuotaExhausted,
|
||||
/// 当前账号只缺少指定模型的访问权限。
|
||||
ModelAccessDenied,
|
||||
/// 上游要求付费,但没有给出可证明账号额度耗尽的稳定信号。
|
||||
PaymentRequired,
|
||||
/// 当前请求被内容安全策略拒绝,不代表账号或模型不可用。
|
||||
SafetyRejected,
|
||||
/// 截止时间或 transport 超时。
|
||||
Timeout,
|
||||
/// 网络/TLS/连接失败。
|
||||
@@ -301,7 +311,8 @@ pub struct GrokInferenceTransportError {
|
||||
retry_after: Option<Duration>,
|
||||
http_version: Option<UpstreamHttpVersion>,
|
||||
request_id: Option<SafeUpstreamValue>,
|
||||
upstream_code: Option<SafeUpstreamValue>,
|
||||
upstream_code: Option<Box<SafeUpstreamValue>>,
|
||||
client_visible_upstream_error: Option<Box<ClientVisibleUpstreamError>>,
|
||||
transport_metrics: GrokInferenceTransportMetrics,
|
||||
credential_recovery_required: bool,
|
||||
sensitive_context_redacted: bool,
|
||||
@@ -319,6 +330,7 @@ impl GrokInferenceTransportError {
|
||||
http_version: None,
|
||||
request_id: None,
|
||||
upstream_code: None,
|
||||
client_visible_upstream_error: None,
|
||||
transport_metrics: GrokInferenceTransportMetrics {
|
||||
headers_ms: None,
|
||||
client_cache_status: None,
|
||||
@@ -359,7 +371,14 @@ impl GrokInferenceTransportError {
|
||||
/// 附着从错误 JSON 中提取并清洗后的稳定机器码。
|
||||
#[must_use]
|
||||
pub fn with_upstream_code(mut self, code: SafeUpstreamValue) -> Self {
|
||||
self.upstream_code = Some(code);
|
||||
self.upstream_code = Some(Box::new(code));
|
||||
self
|
||||
}
|
||||
|
||||
/// 附着仅供原客户端协议展示的结构化上游错误。
|
||||
#[must_use]
|
||||
pub fn with_client_visible_upstream_error(mut self, error: ClientVisibleUpstreamError) -> Self {
|
||||
self.client_visible_upstream_error = Some(Box::new(error));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -420,8 +439,13 @@ impl GrokInferenceTransportError {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn upstream_code(&self) -> Option<&SafeUpstreamValue> {
|
||||
self.upstream_code.as_ref()
|
||||
pub fn upstream_code(&self) -> Option<&SafeUpstreamValue> {
|
||||
self.upstream_code.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_visible_upstream_error(&self) -> Option<&ClientVisibleUpstreamError> {
|
||||
self.client_visible_upstream_error.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -452,6 +476,13 @@ impl fmt::Debug for GrokInferenceTransportError {
|
||||
.field("http_version", &self.http_version)
|
||||
.field("request_id", &self.request_id)
|
||||
.field("upstream_code", &self.upstream_code)
|
||||
.field(
|
||||
"client_visible_upstream_error",
|
||||
&self
|
||||
.client_visible_upstream_error
|
||||
.as_ref()
|
||||
.map(|_| "<present>"),
|
||||
)
|
||||
.field("transport_metrics", &self.transport_metrics)
|
||||
.field(
|
||||
"credential_recovery_required",
|
||||
|
||||
@@ -33,9 +33,9 @@ use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderStoreError, ProviderStorePorts,
|
||||
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
|
||||
@@ -796,6 +796,30 @@ impl ProviderCooldownPort for TestCooldown {
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn put_scoped_if_later(
|
||||
&self,
|
||||
_cooldown: ProviderScopedCooldown,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn read_scoped<'a>(
|
||||
&'a self,
|
||||
_account_id: &'a ProviderAccountId,
|
||||
_scope: &'a ProviderCooldownScope,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
|
||||
fn clear_scoped<'a>(
|
||||
&'a self,
|
||||
_account_id: &'a ProviderAccountId,
|
||||
_scope: &'a ProviderCooldownScope,
|
||||
_through_revision: CredentialRevision,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRuntimePolicy;
|
||||
|
||||
@@ -10,9 +10,10 @@ use gateway_core::engine::credential::{
|
||||
QuotaWriteOutcome, RotationStrategy,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingState,
|
||||
ProviderStoreError,
|
||||
ProviderCooldownScope, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderSchedulingState, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::UpstreamModelId;
|
||||
use provider_xai::{
|
||||
GrokAccountSessionSelector, GrokBillingRequest, GrokBillingTransport,
|
||||
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
|
||||
@@ -119,7 +120,8 @@ impl SelectorFixture {
|
||||
.replace(GrokPlanCatalog::new(
|
||||
GrokCatalogScope::for_account(&account).expect("catalog scope"),
|
||||
Utc::now(),
|
||||
GrokCredentialCatalogSeed::new(["grok-4.5"], None).expect("catalog"),
|
||||
GrokCredentialCatalogSeed::new(["grok-4.5", "grok-4.6"], None)
|
||||
.expect("catalog"),
|
||||
))
|
||||
.await
|
||||
.expect("cache catalog");
|
||||
@@ -183,9 +185,32 @@ impl SelectorFixture {
|
||||
excluded: BTreeSet<ProviderAccountId>,
|
||||
required_account: Option<ProviderAccountId>,
|
||||
strategy: RotationStrategy,
|
||||
) -> GrokSessionSelection {
|
||||
self.request_for_model_with_policy("grok-4.5", excluded, required_account, strategy)
|
||||
}
|
||||
|
||||
fn request_for_model(
|
||||
&self,
|
||||
upstream_model: &str,
|
||||
required_account: Option<ProviderAccountId>,
|
||||
) -> GrokSessionSelection {
|
||||
self.request_for_model_with_policy(
|
||||
upstream_model,
|
||||
BTreeSet::new(),
|
||||
required_account,
|
||||
RotationStrategy::Smart,
|
||||
)
|
||||
}
|
||||
|
||||
fn request_for_model_with_policy(
|
||||
&self,
|
||||
upstream_model: &str,
|
||||
excluded: BTreeSet<ProviderAccountId>,
|
||||
required_account: Option<ProviderAccountId>,
|
||||
strategy: RotationStrategy,
|
||||
) -> GrokSessionSelection {
|
||||
GrokSessionSelection::new(
|
||||
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
UpstreamModelId::new(upstream_model).expect("model"),
|
||||
excluded,
|
||||
required_account,
|
||||
AccountSelectionPolicy::new(
|
||||
@@ -387,6 +412,131 @@ async fn rate_limit_feedback_records_runtime_cooldown_without_persisting_account
|
||||
assert_ne!(next.account_id(), &selected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_payment_required_feedback_only_records_runtime_account_cooldown() {
|
||||
let fixture = SelectorFixture::new(&["payment-required", "available"]).await;
|
||||
let session = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("session");
|
||||
let selected = session.account_id().clone();
|
||||
fixture
|
||||
.selector
|
||||
.record_failure(
|
||||
&session,
|
||||
GrokCredentialFailure::PaymentRequired {
|
||||
retry_after: Some(Duration::from_secs(5)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.account(&selected)
|
||||
.expect("selected account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
assert!(fixture.cooldowns.cooldown(&selected).is_some());
|
||||
let next = fixture
|
||||
.selector
|
||||
.select(fixture.request(BTreeSet::new()))
|
||||
.await
|
||||
.expect("another account remains available");
|
||||
assert_ne!(next.account_id(), &selected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_quota_feedback_blocks_only_the_failed_account_and_model() {
|
||||
let fixture = SelectorFixture::new(&["model-quota", "available"]).await;
|
||||
let failed_model = UpstreamModelId::new("grok-4.5").expect("failed model");
|
||||
let session = fixture
|
||||
.selector
|
||||
.select(fixture.request_for_model(failed_model.as_str(), None))
|
||||
.await
|
||||
.expect("session");
|
||||
let selected = session.account_id().clone();
|
||||
fixture
|
||||
.selector
|
||||
.record_failure(
|
||||
&session,
|
||||
GrokCredentialFailure::ModelQuotaExhausted {
|
||||
upstream_model: failed_model.clone(),
|
||||
retry_after: Some(Duration::from_secs(5)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.account(&selected)
|
||||
.expect("selected account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
let scope = ProviderCooldownScope::upstream_model(failed_model);
|
||||
assert!(
|
||||
fixture
|
||||
.cooldowns
|
||||
.scoped_cooldown(&selected, &scope)
|
||||
.is_some()
|
||||
);
|
||||
let unrelated_model = fixture
|
||||
.selector
|
||||
.select(fixture.request_for_model("grok-4.6", Some(selected.clone())))
|
||||
.await
|
||||
.expect("same account remains valid for another model");
|
||||
assert_eq!(unrelated_model.account_id(), &selected);
|
||||
let failed_model_retry = fixture
|
||||
.selector
|
||||
.select(fixture.request_for_model("grok-4.5", None))
|
||||
.await
|
||||
.expect("another account serves the failed model");
|
||||
assert_ne!(failed_model_retry.account_id(), &selected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_access_feedback_reports_model_cooldown_without_blocking_the_account() {
|
||||
let fixture = SelectorFixture::new(&["model-access"]).await;
|
||||
let model = UpstreamModelId::new("grok-4.5").expect("model");
|
||||
let session = fixture
|
||||
.selector
|
||||
.select(fixture.request_for_model(model.as_str(), None))
|
||||
.await
|
||||
.expect("session");
|
||||
fixture
|
||||
.selector
|
||||
.record_failure(
|
||||
&session,
|
||||
GrokCredentialFailure::ModelAccessDenied {
|
||||
upstream_model: model,
|
||||
retry_after: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.account(session.account_id())
|
||||
.expect("account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
assert!(matches!(
|
||||
fixture
|
||||
.selector
|
||||
.select(fixture.request_for_model("grok-4.5", None))
|
||||
.await,
|
||||
Err(GrokSessionSelectorError::ModelCoolingDown {
|
||||
retry_after: Some(_)
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_stream_feedback_records_runtime_cooldown_without_persisting_account_state() {
|
||||
let fixture = SelectorFixture::new(&["stream-interrupted"]).await;
|
||||
|
||||
@@ -18,7 +18,8 @@ use gateway_core::engine::{
|
||||
UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::{
|
||||
ContinuationFailure, ProviderError, ProviderErrorKind, SafeUpstreamValue,
|
||||
ClientVisibleUpstreamError, ContinuationFailure, ProviderError, ProviderErrorKind,
|
||||
SafeUpstreamValue,
|
||||
};
|
||||
use gateway_core::event::{GatewayEvent, UpstreamHttpVersion};
|
||||
use gateway_core::operation::{
|
||||
@@ -1295,6 +1296,157 @@ async fn explicit_quota_http_429_marks_provider_error_replay_safe() {
|
||||
assert!(error.replay_is_safe());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_http_402_is_retryable_without_account_quota_feedback() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::PaymentRequired,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(402),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).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::PermissionDenied);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::PaymentRequired { retry_after: None }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_quota_transport_failure_preserves_model_scoped_feedback() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(403),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).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::QuotaExhausted);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::ModelQuotaExhausted {
|
||||
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
retry_after: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_access_denial_is_retryable_without_credential_recovery() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::ModelAccessDenied,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(403),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).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::PermissionDenied);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::ModelAccessDenied {
|
||||
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
retry_after: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn safety_rejection_does_not_rotate_or_mutate_account_state() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::SafetyRejected,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(403),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).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::PermissionDenied);
|
||||
assert!(!error.replay_is_safe());
|
||||
assert!(selector.feedback.lock().expect("feedback").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_error_should_preserve_client_visible_upstream_detail() {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
"You have run out of credits",
|
||||
Some("personal_team_blocked_spending_limit".to_owned()),
|
||||
Some("insufficient_quota".to_owned()),
|
||||
)
|
||||
.expect("safe upstream detail");
|
||||
let error = mapped_transport_error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(402)
|
||||
.with_client_visible_upstream_error(detail),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("mapped client-visible detail");
|
||||
|
||||
assert_eq!(
|
||||
(detail.message(), detail.code(), detail.error_type()),
|
||||
(
|
||||
"You have run out of credits",
|
||||
Some("personal_team_blocked_spending_limit"),
|
||||
Some("insufficient_quota"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_http_408_does_not_mark_provider_error_replay_safe() {
|
||||
let error = mapped_transport_error(
|
||||
|
||||
@@ -15,9 +15,10 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderSessionAffinityKey, ProviderSessionAffinityPort, ProviderSessionExclusionPort,
|
||||
ProviderSessionExclusions, ProviderStoreError,
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderSessionAffinityKey,
|
||||
ProviderSessionAffinityPort, ProviderSessionExclusionPort, ProviderSessionExclusions,
|
||||
ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_xai::{
|
||||
@@ -561,12 +562,24 @@ pub struct TestSessionExclusions;
|
||||
#[derive(Default)]
|
||||
pub struct MemoryCooldownPort {
|
||||
cooldowns: Mutex<BTreeMap<ProviderAccountId, ProviderCooldown>>,
|
||||
scoped_cooldowns:
|
||||
Mutex<BTreeMap<(ProviderAccountId, ProviderCooldownScope), ProviderScopedCooldown>>,
|
||||
}
|
||||
|
||||
impl MemoryCooldownPort {
|
||||
pub fn cooldown(&self, account_id: &ProviderAccountId) -> Option<ProviderCooldown> {
|
||||
lock(&self.cooldowns).get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn scoped_cooldown(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
scope: &ProviderCooldownScope,
|
||||
) -> Option<ProviderScopedCooldown> {
|
||||
lock(&self.scoped_cooldowns)
|
||||
.get(&(account_id.clone(), scope.clone()))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderSessionAffinityPort for TestSessionAffinity {
|
||||
@@ -674,6 +687,53 @@ impl ProviderCooldownPort for MemoryCooldownPort {
|
||||
Ok(should_remove)
|
||||
})
|
||||
}
|
||||
|
||||
fn put_scoped_if_later(
|
||||
&self,
|
||||
cooldown: ProviderScopedCooldown,
|
||||
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let key = (cooldown.account_id().clone(), cooldown.scope().clone());
|
||||
let mut cooldowns = lock(&self.scoped_cooldowns);
|
||||
let should_write = cooldowns.get(&key).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(key, cooldown);
|
||||
}
|
||||
Ok(should_write)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
) -> futures::future::BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>
|
||||
{
|
||||
Box::pin(async move { Ok(self.scoped_cooldown(account_id, scope)) })
|
||||
}
|
||||
|
||||
fn clear_scoped<'a>(
|
||||
&'a self,
|
||||
account_id: &'a ProviderAccountId,
|
||||
scope: &'a ProviderCooldownScope,
|
||||
through_revision: CredentialRevision,
|
||||
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let key = (account_id.clone(), scope.clone());
|
||||
let mut cooldowns = lock(&self.scoped_cooldowns);
|
||||
let should_remove = cooldowns
|
||||
.get(&key)
|
||||
.is_some_and(|current| current.credential_revision() <= through_revision);
|
||||
if should_remove {
|
||||
cooldowns.remove(&key);
|
||||
}
|
||||
Ok(should_remove)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRuntimePolicyPort for StaticRuntimePolicy {
|
||||
|
||||
@@ -257,7 +257,7 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
|
||||
let cases = [
|
||||
(400, GrokInferenceTransportErrorKind::InvalidRequest),
|
||||
(401, GrokInferenceTransportErrorKind::Unauthorized),
|
||||
(402, GrokInferenceTransportErrorKind::QuotaExhausted),
|
||||
(402, GrokInferenceTransportErrorKind::PaymentRequired),
|
||||
(403, GrokInferenceTransportErrorKind::PermissionDenied),
|
||||
(408, GrokInferenceTransportErrorKind::Timeout),
|
||||
(429, GrokInferenceTransportErrorKind::RateLimited),
|
||||
@@ -302,10 +302,82 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
|
||||
Some(GrokInferenceClientCacheStatus::Miss)
|
||||
);
|
||||
assert!(error.transport_metrics().headers_ms().is_some());
|
||||
assert!(error.client_visible_upstream_error().is_none());
|
||||
assert!(!rendered.contains(&secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_expose_safe_flat_json_error_details() {
|
||||
let server = MockServer::start().await;
|
||||
let message = "You have run out of credits or need a Grok subscription";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
|
||||
"code": "personal-team-blocked:spending-limit",
|
||||
"error": message,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("spending limit response must fail");
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("structured error detail");
|
||||
let rendered = format!("{error:?}\n{error}");
|
||||
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
);
|
||||
assert_eq!(
|
||||
(detail.message(), detail.code(), detail.error_type()),
|
||||
(message, Some("personal_team_blocked_spending_limit"), None,)
|
||||
);
|
||||
assert!(!rendered.contains(message));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_scrub_nested_json_error_details() {
|
||||
let server = MockServer::start().await;
|
||||
let account_fingerprint = "123e4567-e89b-12d3-a456-426614174000";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
|
||||
"error": {
|
||||
"code": "rate-limit:burst",
|
||||
"type": "rate_limit_error",
|
||||
"message": format!("team {account_fingerprint}\nrate limited"),
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("rate limit response must fail");
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("structured error detail");
|
||||
let rendered = format!("{error:?}\n{error}");
|
||||
|
||||
assert_eq!(
|
||||
(detail.message(), detail.code(), detail.error_type()),
|
||||
(
|
||||
"team [redacted] rate limited",
|
||||
Some("rate_limit_burst"),
|
||||
Some("rate_limit_error"),
|
||||
)
|
||||
);
|
||||
assert!(!rendered.contains(account_fingerprint));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata() {
|
||||
let cases = [
|
||||
@@ -317,14 +389,20 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "usage_exhausted", "message": "used all the included free usage for model"}}),
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted,
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
|
||||
false,
|
||||
Some("usage_exhausted"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "permission_denied", "message": "access to the chat endpoint is denied"}}),
|
||||
GrokInferenceTransportErrorKind::PermissionDenied,
|
||||
true,
|
||||
GrokInferenceTransportErrorKind::ModelAccessDenied,
|
||||
false,
|
||||
Some("permission_denied"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "permission_denied", "message": "request rejected", "details": {"check": "SAFETY_CHECK_TYPE_INPUT"}}}),
|
||||
GrokInferenceTransportErrorKind::SafetyRejected,
|
||||
false,
|
||||
Some("permission_denied"),
|
||||
),
|
||||
(
|
||||
@@ -359,7 +437,62 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_classify_free_usage_429_as_quota_exhausted() {
|
||||
async fn inference_transport_should_keep_unknown_402_out_of_account_quota_state() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
|
||||
"error": {
|
||||
"code": "payment_required",
|
||||
"type": "billing_error",
|
||||
"message": "Payment is required for this request"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("payment-required response must fail");
|
||||
|
||||
assert_eq!(
|
||||
(error.kind(), error.requires_credential_recovery()),
|
||||
(GrokInferenceTransportErrorKind::PaymentRequired, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_classify_model_quota_for_403_and_429() {
|
||||
for status in [403, 429] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
|
||||
"error": {
|
||||
"code": "subscription:free-usage-exhausted",
|
||||
"message": "You have used all the included free usage for model grok-4.5"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("model quota response must fail");
|
||||
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_classify_free_usage_429_as_account_free_quota_exhausted() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
@@ -380,7 +513,7 @@ async fn inference_transport_should_classify_free_usage_429_as_quota_exhausted()
|
||||
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
);
|
||||
assert_eq!(error.status(), Some(429));
|
||||
assert_eq!(
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.19"
|
||||
version: "3.0.20"
|
||||
|
||||
Reference in New Issue
Block a user