mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ea957843 | ||
|
|
c92c95a733 | ||
|
|
ae7608440e | ||
|
|
5c9da8a39d | ||
|
|
e012f24ef2 | ||
|
|
756c9c7158 | ||
|
|
ab4bf75d0e | ||
|
|
7a9715ed41 | ||
|
|
c353a6159e | ||
|
|
f5dca4848a | ||
|
|
36321a9af4 | ||
|
|
b485b8bd4a | ||
|
|
cae2c8656a |
@@ -626,7 +626,7 @@ jobs:
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
body: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Database schema and configuration are only compatible within a major version, and the one-click self-update refuses cross-major targets (releases before 3.0 lack this gate — do not use their built-in update to reach a newer major). Deploy the new major fresh (new `.runtime/` data directory) and re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
|
||||
@@ -168,10 +168,8 @@ docker compose -f deploy/compose.yaml up -d
|
||||
> 运行状态。
|
||||
|
||||
> [!WARNING]
|
||||
> 跨大版本不是原地升级:数据库 schema 与配置只在同一大版本内兼容,内置一键自更新
|
||||
> 会拒绝跨大版本目标(3.0 之前的旧版没有这道门禁,不要用旧版界面的一键更新跨大版本)。
|
||||
> 升级到新的大版本需要全新部署(全新 `.runtime/` 数据目录),并重新导入或重新授权
|
||||
> Provider 账号与客户端 Key。
|
||||
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
|
||||
> 并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
|
||||
@@ -461,7 +461,9 @@ pub struct RequestMetricPoint {
|
||||
|
||||
/// 账号池容量统计。
|
||||
///
|
||||
/// 配额耗尽单列为可恢复状态;`unavailable` 只包含过期、禁用和封禁账号。
|
||||
/// 首页概览的 `unavailable` 表示不可参与调度,包含配额耗尽及过期、禁用和封禁账号。
|
||||
///
|
||||
/// `quota_exhausted` 仍单列,以便状态详情展示和诊断。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
|
||||
@@ -2234,9 +2234,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
|
||||
account_usage,
|
||||
recent_requests,
|
||||
} = observation;
|
||||
// 概览卡只有可用和不可用两桶:额度耗尽可恢复,保留在可用;不可用只含终态账号。
|
||||
let available_accounts =
|
||||
dashboard_available_account_count(provider_accounts.total, provider_accounts.unavailable);
|
||||
let mut account_usage_views = Vec::with_capacity(account_usage.len());
|
||||
let mut credential_usage_views = Vec::with_capacity(account_usage.len());
|
||||
for credential in account_usage {
|
||||
@@ -2285,8 +2282,8 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
|
||||
credentials: DashboardCredentialsCardView {
|
||||
total: format_compact_number(provider_accounts.total),
|
||||
total_value: provider_accounts.total,
|
||||
available: format_compact_number(available_accounts),
|
||||
available_value: available_accounts,
|
||||
available: format_compact_number(provider_accounts.active),
|
||||
available_value: provider_accounts.active,
|
||||
unavailable: format_compact_number(provider_accounts.unavailable),
|
||||
unavailable_value: provider_accounts.unavailable,
|
||||
},
|
||||
@@ -2342,10 +2339,6 @@ fn dashboard_view(result: domain::DashboardResult, kind: TrendKind) -> Dashboard
|
||||
}
|
||||
}
|
||||
|
||||
fn dashboard_available_account_count(total: u64, unavailable: u64) -> u64 {
|
||||
total.saturating_sub(unavailable)
|
||||
}
|
||||
|
||||
fn usage_summary_view(summary: domain::UsageSummary) -> UsageSummaryView {
|
||||
let overview = summary.overview;
|
||||
UsageSummaryView {
|
||||
|
||||
@@ -7,8 +7,8 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{EngineError, UpstreamSendState},
|
||||
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
|
||||
engine::EngineError,
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -72,19 +72,6 @@ pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
|
||||
#[must_use]
|
||||
pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
match error {
|
||||
EngineError::Provider(error)
|
||||
if error.kind() == ProviderErrorKind::Unavailable
|
||||
&& error.send_state() == UpstreamSendState::NotSent =>
|
||||
{
|
||||
let gateway = GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
);
|
||||
match error.client_visible_upstream_error() {
|
||||
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
|
||||
None => gateway,
|
||||
}
|
||||
}
|
||||
EngineError::Provider(error) => GatewayError::from_provider(error),
|
||||
EngineError::Cancelled => {
|
||||
GatewayError::new(GatewayErrorKind::Cancelled, "request was cancelled")
|
||||
@@ -185,6 +172,16 @@ pub const fn gateway_error_contract(
|
||||
"server_error",
|
||||
"no_available_provider",
|
||||
),
|
||||
GatewayErrorKind::AccountCapacityUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"account_capacity_unavailable",
|
||||
),
|
||||
GatewayErrorKind::ProviderInfrastructureUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
),
|
||||
GatewayErrorKind::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
|
||||
@@ -261,7 +261,7 @@ mod response {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in_headline_card() {
|
||||
async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headline_count() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
@@ -288,7 +288,7 @@ async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in
|
||||
provider_accounts: AccountPoolMetrics {
|
||||
total: 807,
|
||||
enabled: 804,
|
||||
unavailable: 7,
|
||||
unavailable: 792,
|
||||
active: 15,
|
||||
expired: 2,
|
||||
quota_exhausted: 785,
|
||||
@@ -328,8 +328,8 @@ async fn dashboard_summary_should_count_quota_exhausted_accounts_as_available_in
|
||||
),
|
||||
(
|
||||
&serde_json::json!(807),
|
||||
&serde_json::json!(800),
|
||||
&serde_json::json!(7)
|
||||
&serde_json::json!(15),
|
||||
&serde_json::json!(792)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,30 @@ fn no_available_provider_error_should_map_to_service_unavailable() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_capacity_error_should_map_to_service_unavailable() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::AccountCapacityUnavailable),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"account_capacity_unavailable",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_infrastructure_error_should_map_to_service_unavailable() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::ProviderInfrastructureUnavailable),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_error_should_map_to_openai_retryable_status() {
|
||||
assert_eq!(
|
||||
@@ -122,29 +146,32 @@ fn engine_provider_error_should_preserve_retry_classification() {
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request"
|
||||
GatewayErrorKind::AccountCapacityUnavailable,
|
||||
"all eligible upstream accounts are temporarily busy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
|
||||
fn no_eligible_provider_account_should_preserve_safe_provider_detail() {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
"no account is eligible for the requested model",
|
||||
Some("no_eligible_account".to_owned()),
|
||||
Some("account_unavailable_error".to_owned()),
|
||||
);
|
||||
let error = EngineError::Provider(
|
||||
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
.with_client_visible_upstream_error(detail),
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(detail),
|
||||
);
|
||||
let gateway = gateway_error_from_engine(&error);
|
||||
|
||||
@@ -164,6 +191,22 @@ fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_infrastructure_failure_should_have_a_distinct_client_contract() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::ProviderInfrastructureUnavailable,
|
||||
"provider account infrastructure is temporarily unavailable"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
@@ -181,16 +224,18 @@ fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_provider_unavailability_should_remain_bad_gateway() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
fn provider_unavailability_should_remain_bad_gateway_regardless_of_send_state() {
|
||||
for send_state in [UpstreamSendState::NotSent, UpstreamSendState::Sent] {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
send_state,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::UpstreamUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::UpstreamUnavailable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -165,8 +165,8 @@ where
|
||||
timings: ModelRequestTimings::default(),
|
||||
client_response_id: None,
|
||||
upstream_response_id: None,
|
||||
last_account_exhaustion: None,
|
||||
last_account_exhaustion_events: Vec::new(),
|
||||
last_retryable_failure: None,
|
||||
last_retryable_failure_events: Vec::new(),
|
||||
provider_attempt_outcomes: Vec::new(),
|
||||
pending_terminal_failure: None,
|
||||
};
|
||||
@@ -245,9 +245,10 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
timings: ModelRequestTimings,
|
||||
client_response_id: Option<String>,
|
||||
upstream_response_id: Option<String>,
|
||||
last_account_exhaustion: Option<ProviderError>,
|
||||
/// 最近一次账号耗尽在重试时被丢弃的原始失败批次;只用于后续空选路终态。
|
||||
last_account_exhaustion_events: Vec<ProviderEvent>,
|
||||
/// 最近一次为无感恢复而被丢弃的原始上游错误;只在后续空选路时成为终态。
|
||||
last_retryable_failure: Option<ProviderError>,
|
||||
/// 与 `last_retryable_failure` 同属一个 attempt 的原始失败批次。
|
||||
last_retryable_failure_events: Vec<ProviderEvent>,
|
||||
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
|
||||
/// 原子失败批次已交给协议层、但尚待下游提交后收敛的原 Provider 错误。
|
||||
pending_terminal_failure: Option<PendingTerminalFailure>,
|
||||
@@ -659,25 +660,30 @@ where
|
||||
ProviderBoundary::Result(result) => match *result {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
if error.kind() == ProviderErrorKind::NoEligibleAccount
|
||||
&& let Some(exhaustion) = self.last_account_exhaustion.take()
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
) && let Some(last_failure) = self.last_retryable_failure.take()
|
||||
{
|
||||
let send_state = self.current_send_state();
|
||||
let mut events = std::mem::take(&mut self.last_account_exhaustion_events);
|
||||
let mut events = std::mem::take(&mut self.last_retryable_failure_events);
|
||||
if !events.is_empty() {
|
||||
self.observe_atomic_terminal_events(&mut events);
|
||||
return Ok(Some(PullOutcome::TerminalFailure {
|
||||
events,
|
||||
error: exhaustion,
|
||||
error: last_failure,
|
||||
send_state,
|
||||
}));
|
||||
}
|
||||
self.finish_provider_error(&exhaustion).await?;
|
||||
return Err(provider_engine_error(exhaustion));
|
||||
self.finish_provider_error(&last_failure).await?;
|
||||
return Err(provider_engine_error(last_failure));
|
||||
}
|
||||
if !(matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::Unavailable | ProviderErrorKind::NoEligibleAccount
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
) && error.send_state() == UpstreamSendState::NotSent)
|
||||
{
|
||||
self.record_provider_failure(candidate.provider().clone(), error.kind());
|
||||
@@ -909,14 +915,10 @@ where
|
||||
&mut self,
|
||||
mut error: ProviderError,
|
||||
) -> Result<StreamErrorOutcome, EngineError> {
|
||||
// 原始 wire 只能活在本次决策栈内;后续 clone、attempt 记录与终态对象均只
|
||||
// 接触已剥离的稳定错误字段。
|
||||
// 原始 wire 只活在 request-local 决策状态;clone、attempt 记录与持久化终态
|
||||
// 均只接触已剥离的稳定错误字段。
|
||||
let mut atomic_client_events = error.take_atomic_client_events();
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
let account_exhausted = matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted
|
||||
);
|
||||
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
|
||||
// attempt_send_state 是本 attempt 自身的发送事实,驱动重试门;
|
||||
// 持久化与终态用请求级水位,二者不可混用(水位会把早先 attempt 的
|
||||
@@ -976,8 +978,8 @@ where
|
||||
continuation_retry || same_account_retry || ordinary_retry || pre_delivery_retry;
|
||||
|
||||
if retryable {
|
||||
// 普通 clone 只保留稳定事实;原始失败响应只能随最后一次客户端可见
|
||||
// 的账号耗尽错误继续,不能进入中间失败记录。
|
||||
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
|
||||
// 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。
|
||||
let persistence_error = self.request_persisted.then(|| error.clone());
|
||||
if same_account_retry {
|
||||
if let Some(account) = current.metadata.provider_account_id() {
|
||||
@@ -992,10 +994,8 @@ where
|
||||
{
|
||||
self.excluded_accounts.insert(account.clone());
|
||||
}
|
||||
if account_exhausted {
|
||||
self.last_account_exhaustion_events = atomic_client_events;
|
||||
self.last_account_exhaustion = Some(error);
|
||||
}
|
||||
self.last_retryable_failure_events = atomic_client_events;
|
||||
self.last_retryable_failure = Some(error);
|
||||
if let Some(error) = persistence_error {
|
||||
best_effort_store_write(
|
||||
"record_intermediate_failure",
|
||||
|
||||
@@ -129,8 +129,12 @@ pub enum ProviderErrorKind {
|
||||
RateLimited,
|
||||
/// Credential 配额耗尽。
|
||||
QuotaExhausted,
|
||||
/// 仍有符合条件的账号,但它们暂时没有可调度容量。
|
||||
AccountCapacityUnavailable,
|
||||
/// Provider 已确认当前请求无法选出可用账号。
|
||||
NoEligibleAccount,
|
||||
/// Provider 的账号存储、租约协调或本地凭据数据不可用。
|
||||
ProviderInfrastructureUnavailable,
|
||||
/// 请求超时。
|
||||
Timeout,
|
||||
/// 网络或 transport 失败。
|
||||
@@ -156,7 +160,9 @@ impl ProviderErrorKind {
|
||||
Self::PermissionDenied => "permission_denied",
|
||||
Self::RateLimited => "rate_limited",
|
||||
Self::QuotaExhausted => "quota_exhausted",
|
||||
Self::AccountCapacityUnavailable => "account_capacity_unavailable",
|
||||
Self::NoEligibleAccount => "no_eligible_account",
|
||||
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Transport => "transport",
|
||||
Self::Protocol => "protocol",
|
||||
@@ -711,6 +717,10 @@ pub enum GatewayErrorKind {
|
||||
ModelNotFound,
|
||||
/// 当前没有可用 target。
|
||||
NoAvailableProvider,
|
||||
/// 符合条件的上游账号暂时没有调度容量。
|
||||
AccountCapacityUnavailable,
|
||||
/// Provider 的本地账号基础设施不可用。
|
||||
ProviderInfrastructureUnavailable,
|
||||
/// 上游限流。
|
||||
RateLimited,
|
||||
/// 上游暂不可用。
|
||||
@@ -734,6 +744,8 @@ impl GatewayErrorKind {
|
||||
Self::PolicyDenied => "policy_denied",
|
||||
Self::ModelNotFound => "model_not_found",
|
||||
Self::NoAvailableProvider => "no_available_provider",
|
||||
Self::AccountCapacityUnavailable => "account_capacity_unavailable",
|
||||
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
|
||||
Self::RateLimited => "rate_limited",
|
||||
Self::UpstreamUnavailable => "upstream_unavailable",
|
||||
Self::Timeout => "timeout",
|
||||
@@ -782,10 +794,18 @@ impl GatewayError {
|
||||
GatewayErrorKind::RateLimited,
|
||||
"upstream capacity is temporarily unavailable",
|
||||
),
|
||||
ProviderErrorKind::AccountCapacityUnavailable => Self::new(
|
||||
GatewayErrorKind::AccountCapacityUnavailable,
|
||||
"all eligible upstream accounts are temporarily busy",
|
||||
),
|
||||
ProviderErrorKind::NoEligibleAccount => Self::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
),
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable => Self::new(
|
||||
GatewayErrorKind::ProviderInfrastructureUnavailable,
|
||||
"provider account infrastructure is temporarily unavailable",
|
||||
),
|
||||
ProviderErrorKind::Timeout => {
|
||||
Self::new(GatewayErrorKind::Timeout, "upstream request timed out")
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ use gateway_core::engine::{
|
||||
ProviderAttemptOutcome, RecoveryReport, UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamResponse, ContinuationFailure, GatewayErrorKind, OpaqueUpstreamValue,
|
||||
ProviderError, ProviderErrorKind, StoreError, StoreErrorKind,
|
||||
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ContinuationFailure,
|
||||
GatewayErrorKind, OpaqueUpstreamValue, ProviderError, ProviderErrorKind, StoreError,
|
||||
StoreErrorKind,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
ContentItem, ContentKind, GatewayEvent, ProtocolWireEvent, ProviderEvent,
|
||||
@@ -2554,6 +2555,128 @@ fn pre_delivery_transport_failure_rotates_account_for_non_idempotent_generation(
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
|
||||
let operation = operation(RetrySafety::NonIdempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let raw_body = Bytes::from_static(b"{\"error\":\"last upstream body\"}\x00");
|
||||
let upstream_error =
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
|
||||
.with_pre_delivery_retry()
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"message too big",
|
||||
Some("1009".to_owned()),
|
||||
Some("websocket_close_error".to_owned()),
|
||||
))
|
||||
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
|
||||
502,
|
||||
Some(b"application/json".to_vec()),
|
||||
raw_body.clone(),
|
||||
));
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(upstream_error)],
|
||||
},
|
||||
Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
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 error = block_on(session.collect_uncommitted())
|
||||
.expect_err("empty selection must return the retained upstream failure");
|
||||
let EngineError::Provider(error) = error else {
|
||||
panic!("expected retained provider error");
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Transport);
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("structured upstream detail");
|
||||
assert_eq!(detail.message(), "message too big");
|
||||
assert_eq!(detail.code(), Some("1009"));
|
||||
assert_eq!(detail.error_type(), Some("websocket_close_error"));
|
||||
let response = error
|
||||
.client_visible_upstream_response()
|
||||
.expect("original upstream response");
|
||||
assert_eq!(response.status(), 502);
|
||||
assert_eq!(response.body(), &raw_body);
|
||||
|
||||
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::Failed);
|
||||
assert_eq!(
|
||||
state.finalizations[0].send_state,
|
||||
UpstreamSendState::Ambiguous
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_infrastructure_failure_does_not_restore_the_last_upstream_failure() {
|
||||
let operation = operation(RetrySafety::NonIdempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let upstream_error =
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
|
||||
.with_pre_delivery_retry()
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"upstream marker",
|
||||
Some("upstream_marker".to_owned()),
|
||||
Some("upstream_error".to_owned()),
|
||||
))
|
||||
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
|
||||
502,
|
||||
Some(b"application/json".to_vec()),
|
||||
Bytes::from_static(b"{\"error\":\"upstream marker\"}"),
|
||||
));
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(upstream_error)],
|
||||
},
|
||||
Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable,
|
||||
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 error = block_on(session.collect_uncommitted())
|
||||
.expect_err("provider infrastructure failure must remain terminal");
|
||||
let EngineError::Provider(error) = error else {
|
||||
panic!("expected provider error");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
);
|
||||
assert!(error.client_visible_upstream_error().is_none());
|
||||
assert!(error.client_visible_upstream_response().is_none());
|
||||
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::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_delivery_retry_marker_is_ignored_after_downstream_commit() {
|
||||
let operation = operation(RetrySafety::NonIdempotent);
|
||||
|
||||
@@ -2013,8 +2013,7 @@ async fn provider_account_metrics(
|
||||
)
|
||||
select count(*)::bigint as total,
|
||||
count(*) filter (where enabled)::bigint as enabled,
|
||||
count(*) filter (where status in ('expired', 'disabled', 'banned'))::bigint
|
||||
as unavailable,
|
||||
count(*) filter (where status <> 'active')::bigint as unavailable,
|
||||
count(*) filter (where status = 'active')::bigint as active,
|
||||
count(*) filter (where status = 'expired')::bigint as expired,
|
||||
count(*) filter (where status = 'quota_exhausted')::bigint as quota_exhausted,
|
||||
|
||||
@@ -630,7 +630,8 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
else availability_observed_at
|
||||
end,
|
||||
updated_at = case when enabled then greatest(now(), $6) else updated_at end
|
||||
where id = $1 and credential_revision = $2",
|
||||
where id = $1 and credential_revision = $2
|
||||
and (availability_observed_at is null or availability_observed_at <= $6)",
|
||||
)
|
||||
.bind(update.account_id)
|
||||
.bind(to_i64(update.expected_revision.get())?)
|
||||
@@ -675,7 +676,8 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
"update provider_accounts
|
||||
set provider_quota_json = $3, quota_observed_at = $4,
|
||||
updated_at = greatest(now(), coalesce($4, now()))
|
||||
where id = $1 and credential_revision = $2",
|
||||
where id = $1 and credential_revision = $2
|
||||
and ($4 is null or quota_observed_at is null or quota_observed_at <= $4)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(to_i64(expected_revision.get())?)
|
||||
|
||||
@@ -58,30 +58,6 @@ impl TestDatabase {
|
||||
.execute(&mut *migration)
|
||||
.await
|
||||
.expect("apply terminal migration");
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../migrations/0002_drop_opaque_response_id_index.sql"
|
||||
))
|
||||
.execute(&mut *migration)
|
||||
.await
|
||||
.expect("apply opaque response ID migration");
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../migrations/0003_store_opaque_response_ids_as_bytes.sql"
|
||||
))
|
||||
.execute(&mut *migration)
|
||||
.await
|
||||
.expect("apply opaque response storage migration");
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../migrations/0004_add_model_request_service_tier.sql"
|
||||
))
|
||||
.execute(&mut *migration)
|
||||
.await
|
||||
.expect("apply model request service tier migration");
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../migrations/0005_allow_quota_exhausted_cooldown.sql"
|
||||
))
|
||||
.execute(&mut *migration)
|
||||
.await
|
||||
.expect("apply quota exhausted cooldown migration");
|
||||
migration.commit().await.expect("commit terminal migration");
|
||||
Some(Self {
|
||||
admin,
|
||||
@@ -177,25 +153,19 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
|
||||
.expect("drop migration test database");
|
||||
admin.close().await;
|
||||
|
||||
assert_eq!((first_table_count, migration_count), (8, 5));
|
||||
assert_eq!((first_table_count, migration_count), (8, 1));
|
||||
assert_eq!(response_id_types, ["bytea", "bytea"]);
|
||||
assert!(!raw_response_id_index_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_should_leave_transaction_ownership_to_sqlx() {
|
||||
let transaction_statements = [
|
||||
include_str!("../../../../migrations/0001_initial.sql"),
|
||||
include_str!("../../../../migrations/0002_drop_opaque_response_id_index.sql"),
|
||||
include_str!("../../../../migrations/0003_store_opaque_response_ids_as_bytes.sql"),
|
||||
include_str!("../../../../migrations/0004_add_model_request_service_tier.sql"),
|
||||
include_str!("../../../../migrations/0005_allow_quota_exhausted_cooldown.sql"),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(str::lines)
|
||||
.map(str::trim)
|
||||
.filter(|line| matches!(*line, "begin;" | "commit;"))
|
||||
.count();
|
||||
let transaction_statements = [include_str!("../../../../migrations/0001_initial.sql")]
|
||||
.into_iter()
|
||||
.flat_map(str::lines)
|
||||
.map(str::trim)
|
||||
.filter(|line| matches!(*line, "begin;" | "commit;"))
|
||||
.count();
|
||||
|
||||
assert_eq!(transaction_statements, 0);
|
||||
}
|
||||
|
||||
@@ -169,12 +169,14 @@ async fn dashboard_account_metrics_should_partition_account_statuses() {
|
||||
metrics.disabled,
|
||||
metrics.banned,
|
||||
),
|
||||
(6, 5, 2, 3, 1, 1, 1, 1),
|
||||
(6, 5, 2, 4, 1, 1, 1, 1),
|
||||
);
|
||||
// 首页按调度可用性聚合:配额耗尽也属于不可用。
|
||||
assert_eq!(
|
||||
metrics.total,
|
||||
metrics.active + metrics.quota_exhausted + metrics.unavailable
|
||||
metrics.unavailable,
|
||||
metrics.quota_exhausted + metrics.expired + metrics.disabled + metrics.banned
|
||||
);
|
||||
assert_eq!(metrics.total, metrics.active + metrics.unavailable);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,97 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
|
||||
let Some(database) = TestDatabase::create("provider_account_observation_fence").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let account_id = ProviderAccountId::new("acct_observation_fence").expect("account id");
|
||||
repository
|
||||
.insert_provider_account(account(account_id.as_str(), "user-observation-fence"))
|
||||
.await
|
||||
.expect("insert observation fence fixture");
|
||||
let revision = CredentialRevision::new(1).expect("revision");
|
||||
let newer = SystemTime::now()
|
||||
.checked_add(Duration::from_secs(60))
|
||||
.expect("newer observation time");
|
||||
let older = newer
|
||||
.checked_sub(Duration::from_secs(30))
|
||||
.expect("older observation time");
|
||||
|
||||
let quota = |marker: &str, observed_at| QuotaObservation {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({"marker": marker})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(observed_at),
|
||||
};
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_quota(quota("newer", newer))
|
||||
.await
|
||||
.expect("persist newer quota"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_quota(quota("older", older))
|
||||
.await
|
||||
.expect("reject older quota"),
|
||||
QuotaWriteOutcome::Conflict
|
||||
);
|
||||
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("quota_exhausted".to_owned()),
|
||||
cooldown_until: Some(newer),
|
||||
observed_at: newer,
|
||||
})
|
||||
.await
|
||||
.expect("persist newer account state");
|
||||
assert!(
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
availability: AccountAvailability::Ready,
|
||||
reason: None,
|
||||
cooldown_until: None,
|
||||
observed_at: older,
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"a delayed state observation must conflict"
|
||||
);
|
||||
|
||||
let current = repository
|
||||
.get_account(&account_id)
|
||||
.await
|
||||
.expect("load fenced account")
|
||||
.expect("fenced account");
|
||||
assert_eq!(current.availability(), AccountAvailability::QuotaExhausted);
|
||||
let observed = repository
|
||||
.get_quotas(std::slice::from_ref(&account_id))
|
||||
.await
|
||||
.expect("load fenced quota")
|
||||
.pop()
|
||||
.expect("fenced quota");
|
||||
assert_eq!(
|
||||
observed.quota.expect("quota document").expose_to_provider()["marker"],
|
||||
"newer"
|
||||
);
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_usage() {
|
||||
let Some(database) = TestDatabase::create("provider_account_terminal_list").await else {
|
||||
|
||||
@@ -25,8 +25,9 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
AccountAvailability, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -458,21 +459,23 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
refresh,
|
||||
rolling_usage: _,
|
||||
} = request;
|
||||
self.account(&account_id).await?;
|
||||
let mut account = self.account(&account_id).await?;
|
||||
let snapshot = if refresh {
|
||||
Some(
|
||||
let snapshot = Some(
|
||||
self.quota
|
||||
.refresh_account(&account_id)
|
||||
.await
|
||||
.map_err(map_quota_error)?,
|
||||
)
|
||||
);
|
||||
account = self.account(&account_id).await?;
|
||||
snapshot
|
||||
} else {
|
||||
self.quota
|
||||
.read_account(&account_id)
|
||||
.await
|
||||
.map_err(map_quota_error)?
|
||||
};
|
||||
Ok(snapshot.map_or_else(empty_quota, project_quota))
|
||||
Ok(project_quota(snapshot, &account))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
@@ -746,7 +749,18 @@ fn empty_quota() -> ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
fn project_quota(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota {
|
||||
fn project_quota(
|
||||
snapshot: Option<CodexAccountQuotaSnapshot>,
|
||||
account: &ProviderAccount,
|
||||
) -> ProviderQuota {
|
||||
let mut quota = snapshot.map_or_else(empty_quota, project_quota_snapshot);
|
||||
if account.availability() == AccountAvailability::QuotaExhausted {
|
||||
force_confirmed_exhaustion_projection(&mut quota, account.cooldown_until());
|
||||
}
|
||||
quota
|
||||
}
|
||||
|
||||
fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota {
|
||||
let mut provider_data = Map::new();
|
||||
provider_data.insert(
|
||||
"remaining_percent".to_owned(),
|
||||
@@ -799,6 +813,68 @@ fn project_quota(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
fn force_confirmed_exhaustion_projection(
|
||||
quota: &mut ProviderQuota,
|
||||
cooldown_until: Option<SystemTime>,
|
||||
) {
|
||||
// 真实 429 对“当前已耗尽”的证明强于可能延迟结算的 usage 文档。这里只覆盖
|
||||
// Admin 展示投影,原始 Provider JSON 仍作为后续判断窗口是否真正恢复的基线。
|
||||
quota.provider_data = Some(ProviderDocument::new(OpaqueProviderData::new(
|
||||
Map::from_iter([
|
||||
(
|
||||
"remaining_percent".to_owned(),
|
||||
Value::Number(Number::from(0)),
|
||||
),
|
||||
("exhausted".to_owned(), Value::Bool(true)),
|
||||
]),
|
||||
)));
|
||||
let reset_at = cooldown_until.map(DateTime::<Utc>::from);
|
||||
if quota.windows.is_empty() {
|
||||
quota.windows.push(ProviderQuotaWindow {
|
||||
key: "confirmed-quota-exhaustion".to_owned(),
|
||||
group: "other".to_owned(),
|
||||
label: "额度".to_owned(),
|
||||
source: None,
|
||||
window_seconds: None,
|
||||
used_percent: Some(100.0),
|
||||
reset_at,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let matched_reset = reset_at.and_then(|reset_at| {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, window)| {
|
||||
let distance = window.reset_at.map(|window_reset| {
|
||||
(window_reset.timestamp() - reset_at.timestamp()).unsigned_abs()
|
||||
})?;
|
||||
Some((index, distance))
|
||||
})
|
||||
.min_by_key(|(_, distance)| *distance)
|
||||
.map(|(index, _)| index)
|
||||
});
|
||||
let index = matched_reset
|
||||
.or_else(|| {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, left), (_, right)| {
|
||||
left.used_percent
|
||||
.unwrap_or(-1.0)
|
||||
.total_cmp(&right.used_percent.unwrap_or(-1.0))
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
quota.windows[index].used_percent = Some(100.0);
|
||||
}
|
||||
|
||||
const fn quota_group(kind: CodexQuotaWindowKind) -> &'static str {
|
||||
match kind {
|
||||
CodexQuotaWindowKind::Monthly => "monthly",
|
||||
|
||||
@@ -217,7 +217,7 @@ pub struct CodexQuotaSettings {
|
||||
impl Default for CodexQuotaSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
refresh_interval_minutes: 5,
|
||||
refresh_interval_minutes: 15,
|
||||
skip_exhausted: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,12 @@ const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60 * 60);
|
||||
const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
|
||||
const QUOTA_PERIODIC_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const QUOTA_RESET_RECHECK_GRACE: Duration = Duration::from_secs(30);
|
||||
// ready 账号主要依赖真实请求的被动观察;这里只提供低频、定额的状态兜底。
|
||||
const QUOTA_READY_REFRESH_MAX_AGE: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
const QUOTA_READY_REFRESH_BATCH_SIZE: usize = 8;
|
||||
|
||||
/// OpenAI Provider 主动额度刷新的调度策略。
|
||||
///
|
||||
/// 账号筛选仍由 core 的可调度状态统一处理;该策略只控制 Provider 自己的
|
||||
/// quota worker 频率,避免把 OpenAI 额度语义泄漏到公共调度层。
|
||||
/// 正常账号依赖请求响应的被动额度同步;周期 worker 仅复核已耗尽账号。
|
||||
/// 该策略只控制 Provider 自己的 quota worker 频率,避免把 OpenAI
|
||||
/// 额度语义泄漏到公共调度层。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CodexQuotaRefreshPolicy {
|
||||
interval: Duration,
|
||||
@@ -197,6 +194,12 @@ pub struct CodexQuotaSyncSummary {
|
||||
pub stale: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum QuotaRecoveryEvidence {
|
||||
SuccessfulResponse,
|
||||
RefreshedSnapshot,
|
||||
}
|
||||
|
||||
impl CodexQuotaSyncSummary {
|
||||
#[must_use]
|
||||
pub const fn has_operational_failures(self) -> bool {
|
||||
@@ -249,8 +252,6 @@ struct CodexQuotaProjectionState {
|
||||
next_version: u64,
|
||||
entries: BTreeMap<ProviderAccountId, CodexQuotaSchedulingEntry>,
|
||||
last_periodic_refresh_at: BTreeMap<ProviderAccountId, Instant>,
|
||||
last_exhausted_reset_recheck: BTreeMap<ProviderAccountId, CodexQuotaResetMarker>,
|
||||
ready_refresh_cursor: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -261,24 +262,6 @@ struct CodexQuotaSchedulingEntry {
|
||||
signals: Option<AccountQuotaSignals>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct CodexQuotaResetMarker {
|
||||
revision: CredentialRevision,
|
||||
reset_at: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexQuotaRefreshKind {
|
||||
Exhausted { reset_at: Option<SystemTime> },
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CodexQuotaRefreshCandidate {
|
||||
account: ProviderAccount,
|
||||
kind: CodexQuotaRefreshKind,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CodexQuotaHydrationTarget {
|
||||
account: ProviderAccount,
|
||||
@@ -461,16 +444,15 @@ impl CodexQuotaSchedulingProjection {
|
||||
fn reserve_periodic_refreshes(
|
||||
&self,
|
||||
accounts: Vec<ProviderAccount>,
|
||||
observations: Option<&BTreeMap<ProviderAccountId, QuotaObservation>>,
|
||||
now: SystemTime,
|
||||
) -> Vec<ProviderAccount> {
|
||||
let candidates = accounts
|
||||
.into_iter()
|
||||
.filter_map(|account| quota_refresh_candidate(account, observations, now))
|
||||
.filter_map(|account| quota_refresh_candidate(account, now))
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_ids = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.account.id().clone())
|
||||
.map(|account| account.id().clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let refreshed_at = Instant::now();
|
||||
let mut state = self
|
||||
@@ -480,95 +462,27 @@ impl CodexQuotaSchedulingProjection {
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.retain(|account_id, _| candidate_ids.contains(account_id));
|
||||
state
|
||||
.last_exhausted_reset_recheck
|
||||
.retain(|account_id, _| candidate_ids.contains(account_id));
|
||||
|
||||
// 正常账号只由真实请求的响应头和 `codex.rate_limits` 被动同步。定时器只兜底
|
||||
// 已耗尽账号,且不依据 reset 时间跳过复核;每个账号仍至少间隔 30 分钟。
|
||||
let mut reserved = Vec::new();
|
||||
let mut ready = Vec::new();
|
||||
for candidate in candidates {
|
||||
match candidate.kind {
|
||||
CodexQuotaRefreshKind::Exhausted { reset_at } => {
|
||||
if reserve_exhausted_quota_refresh(
|
||||
&mut state,
|
||||
&candidate.account,
|
||||
reset_at,
|
||||
now,
|
||||
refreshed_at,
|
||||
) {
|
||||
reserved.push(candidate.account);
|
||||
}
|
||||
}
|
||||
CodexQuotaRefreshKind::Ready => ready.push(candidate.account),
|
||||
for account in candidates {
|
||||
if !periodic_quota_refresh_due(&state, account.id(), refreshed_at) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ready.sort_by(|left, right| left.id().cmp(right.id()));
|
||||
let ready = ready
|
||||
.into_iter()
|
||||
.filter(|account| periodic_quota_refresh_due(&state, account.id(), refreshed_at))
|
||||
.collect::<Vec<_>>();
|
||||
let ready_len = ready.len();
|
||||
if ready_len == 0 {
|
||||
return reserved;
|
||||
}
|
||||
let start = state
|
||||
.ready_refresh_cursor
|
||||
.as_ref()
|
||||
.and_then(|cursor| ready.iter().position(|account| account.id() > cursor))
|
||||
.unwrap_or(0);
|
||||
let ready = ready
|
||||
.into_iter()
|
||||
.cycle()
|
||||
.skip(start)
|
||||
.take(ready_len.min(QUOTA_READY_REFRESH_BATCH_SIZE))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(last) = ready.last() {
|
||||
state.ready_refresh_cursor = Some(last.id().clone());
|
||||
}
|
||||
for account in &ready {
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.insert(account.id().clone(), refreshed_at);
|
||||
reserved.push(account);
|
||||
}
|
||||
reserved.extend(ready);
|
||||
reserved
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_refresh_candidate(
|
||||
account: ProviderAccount,
|
||||
observations: Option<&BTreeMap<ProviderAccountId, QuotaObservation>>,
|
||||
now: SystemTime,
|
||||
) -> Option<CodexQuotaRefreshCandidate> {
|
||||
if !eligible_periodic_quota_refresh(&account, now) {
|
||||
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: account
|
||||
.cooldown_until()
|
||||
.or_else(|| 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) =>
|
||||
{
|
||||
CodexQuotaRefreshKind::Ready
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(CodexQuotaRefreshCandidate { account, kind })
|
||||
fn quota_refresh_candidate(account: ProviderAccount, now: SystemTime) -> Option<ProviderAccount> {
|
||||
(eligible_periodic_quota_refresh(&account, now)
|
||||
&& account.availability() == AccountAvailability::QuotaExhausted)
|
||||
.then_some(account)
|
||||
}
|
||||
|
||||
fn quota_snapshot_for_account(
|
||||
@@ -597,72 +511,6 @@ fn quota_observation_confirms_active_exhaustion(
|
||||
.is_some_and(|snapshot| quota_fact_is_active_exhaustion(snapshot.fact(), now))
|
||||
}
|
||||
|
||||
fn quota_observation_is_stale(
|
||||
account: &ProviderAccount,
|
||||
observation: Option<&QuotaObservation>,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
let Some(observation) = observation.filter(|observation| {
|
||||
observation.expected_revision == account.revision()
|
||||
&& quota_snapshot_from_observation(observation).is_some()
|
||||
}) else {
|
||||
return true;
|
||||
};
|
||||
observation
|
||||
.observed_at
|
||||
.and_then(|observed_at| now.duration_since(observed_at).ok())
|
||||
.is_none_or(|age| age >= QUOTA_READY_REFRESH_MAX_AGE)
|
||||
}
|
||||
|
||||
fn reserve_exhausted_quota_refresh(
|
||||
state: &mut CodexQuotaProjectionState,
|
||||
account: &ProviderAccount,
|
||||
reset_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
refreshed_at: Instant,
|
||||
) -> bool {
|
||||
if let Some(reset_at) = reset_at {
|
||||
if !quota_reset_recheck_ready(reset_at, now) {
|
||||
return false;
|
||||
}
|
||||
let same_reset_already_checked = state
|
||||
.last_exhausted_reset_recheck
|
||||
.get(account.id())
|
||||
.is_some_and(|marker| {
|
||||
marker.revision == account.revision() && marker.reset_at == reset_at
|
||||
});
|
||||
if same_reset_already_checked
|
||||
&& !periodic_quota_refresh_due(state, account.id(), refreshed_at)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.insert(account.id().clone(), refreshed_at);
|
||||
state.last_exhausted_reset_recheck.insert(
|
||||
account.id().clone(),
|
||||
CodexQuotaResetMarker {
|
||||
revision: account.revision(),
|
||||
reset_at,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if !periodic_quota_refresh_due(state, account.id(), refreshed_at) {
|
||||
return false;
|
||||
}
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.insert(account.id().clone(), refreshed_at);
|
||||
true
|
||||
}
|
||||
|
||||
fn quota_reset_recheck_ready(reset_at: SystemTime, now: SystemTime) -> bool {
|
||||
reset_at
|
||||
.checked_add(QUOTA_RESET_RECHECK_GRACE)
|
||||
.is_some_and(|ready_at| ready_at <= now)
|
||||
}
|
||||
|
||||
fn periodic_quota_refresh_due(
|
||||
state: &CodexQuotaProjectionState,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -774,16 +622,14 @@ impl CodexCredentialQuotaService {
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
Err(error) => {
|
||||
// 持久观察不可用时不盲扫 ready;保留已耗尽账号的既有恢复轮询。
|
||||
tracing::warn!(error = %error, "OpenAI quota refresh skipped ready-account scan");
|
||||
// 持久观察不可用时跳过旧状态修正;仍复核当前已标记耗尽的账号。
|
||||
tracing::warn!(error = %error, "OpenAI quota refresh skipped persisted exhaustion reconciliation");
|
||||
None
|
||||
}
|
||||
};
|
||||
self.reconcile_persisted_exhaustion(&mut accounts, observations.as_ref(), now)
|
||||
.await;
|
||||
let accounts =
|
||||
self.scheduling
|
||||
.reserve_periodic_refreshes(accounts, observations.as_ref(), now);
|
||||
let accounts = self.scheduling.reserve_periodic_refreshes(accounts, now);
|
||||
if accounts.is_empty() {
|
||||
return Ok(summary);
|
||||
}
|
||||
@@ -915,7 +761,13 @@ impl CodexCredentialQuotaService {
|
||||
observed_at: SystemTime,
|
||||
summary: &mut CodexQuotaSyncSummary,
|
||||
) -> Result<(), CodexCredentialQuotaError> {
|
||||
let fact = parse_codex_quota_usage(value)?;
|
||||
let snapshot = parse_account_quota_snapshot(
|
||||
account.id().clone(),
|
||||
account.revision(),
|
||||
observed_at,
|
||||
value,
|
||||
)?;
|
||||
let fact = snapshot.fact();
|
||||
let object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
@@ -953,6 +805,7 @@ impl CodexCredentialQuotaService {
|
||||
current.cooldown_until(),
|
||||
fact,
|
||||
SystemTime::now(),
|
||||
QuotaRecoveryEvidence::RefreshedSnapshot,
|
||||
) {
|
||||
let _ = self
|
||||
.repository
|
||||
@@ -1019,6 +872,7 @@ impl CodexCredentialQuotaService {
|
||||
current.cooldown_until(),
|
||||
fact,
|
||||
observed_at,
|
||||
QuotaRecoveryEvidence::SuccessfulResponse,
|
||||
) {
|
||||
self.repository
|
||||
.apply_state(
|
||||
@@ -1094,6 +948,21 @@ impl CodexCredentialQuotaService {
|
||||
pub async fn refresh_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
|
||||
self.refresh_account_snapshot(account_id).await
|
||||
}
|
||||
|
||||
/// 真实限额失败后的异步刷新只补齐展示快照,不允许 usage 快照撤销已确认的失败状态。
|
||||
pub(crate) async fn refresh_account_after_failure(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
|
||||
self.refresh_account_snapshot(account_id).await
|
||||
}
|
||||
|
||||
async fn refresh_account_snapshot(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
|
||||
let account = self
|
||||
.store
|
||||
@@ -1177,6 +1046,7 @@ impl CodexCredentialQuotaService {
|
||||
current.cooldown_until(),
|
||||
snapshot.fact(),
|
||||
SystemTime::now(),
|
||||
QuotaRecoveryEvidence::RefreshedSnapshot,
|
||||
) {
|
||||
self.repository
|
||||
.apply_state(
|
||||
@@ -1399,9 +1269,13 @@ fn quota_success_state(
|
||||
cooldown_until: Option<SystemTime>,
|
||||
fact: CodexQuotaFact,
|
||||
now: SystemTime,
|
||||
recovery_evidence: QuotaRecoveryEvidence,
|
||||
) -> Option<(AccountAvailability, Option<SystemTime>)> {
|
||||
if fact.exhausted() {
|
||||
let observed_cooldown = fact.resets_at().map(SystemTime::from).or(cooldown_until);
|
||||
let observed_cooldown = match (fact.resets_at().map(SystemTime::from), cooldown_until) {
|
||||
(Some(observed), Some(confirmed)) => Some(observed.max(confirmed)),
|
||||
(observed, confirmed) => observed.or(confirmed),
|
||||
};
|
||||
return (current != AccountAvailability::QuotaExhausted
|
||||
|| cooldown_until != observed_cooldown)
|
||||
.then_some((AccountAvailability::QuotaExhausted, observed_cooldown));
|
||||
@@ -1411,8 +1285,13 @@ fn quota_success_state(
|
||||
AccountAvailability::Invalid
|
||||
| AccountAvailability::Expired
|
||||
| AccountAvailability::Banned
|
||||
| AccountAvailability::Unknown
|
||||
| AccountAvailability::QuotaExhausted => Some(AccountAvailability::Ready),
|
||||
| AccountAvailability::Unknown => Some(AccountAvailability::Ready),
|
||||
AccountAvailability::QuotaExhausted
|
||||
if !quota_recovery_confirmed(recovery_evidence, cooldown_until, now) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
AccountAvailability::QuotaExhausted => Some(AccountAvailability::Ready),
|
||||
AccountAvailability::Ready => None,
|
||||
AccountAvailability::Cooldown
|
||||
if cooldown_until.is_some_and(|cooldown_until| cooldown_until <= now) =>
|
||||
@@ -1424,6 +1303,22 @@ fn quota_success_state(
|
||||
availability.map(|availability| (availability, None))
|
||||
}
|
||||
|
||||
fn quota_recovery_confirmed(
|
||||
evidence: QuotaRecoveryEvidence,
|
||||
cooldown_until: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
if cooldown_until.is_some_and(|cooldown_until| cooldown_until <= now) {
|
||||
return true;
|
||||
}
|
||||
match evidence {
|
||||
QuotaRecoveryEvidence::SuccessfulResponse => true,
|
||||
// used_percent 是可滞后、会取整的观测值;确认 429 后的 100% 也是
|
||||
// 管理端投影。它们的回落不能覆盖仍未到期的上游 reset 时间。
|
||||
QuotaRecoveryEvidence::RefreshedSnapshot => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_state_transition(
|
||||
error: &CodexClientError,
|
||||
observed_at: SystemTime,
|
||||
|
||||
@@ -64,8 +64,7 @@ use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::request::{
|
||||
CodexRequestEncodeError, encode_generate_request, sanitize_cross_account_item,
|
||||
scope_request_to_account,
|
||||
CodexRequestEncodeError, encode_generate_request, scope_request_to_account,
|
||||
};
|
||||
use crate::transport::session::CodexSessionIdentity;
|
||||
use crate::transport::usage::normalize_service_tier;
|
||||
@@ -84,6 +83,12 @@ const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
|
||||
/// 提交边界前最多保留 64 KiB 原始上游 chunk;达到阈值后结束无感换号窗口,
|
||||
/// 但不会把上游数据改写成协议失败。
|
||||
const MAX_STREAM_PREFETCH_BYTES: usize = 64 * 1024;
|
||||
/// 短暂保留 response.created 等结构事件,让随后到达的明确拒绝可以无感换号;
|
||||
/// 到期即放行,避免模型长时间思考时让客户端一直收不到首事件。
|
||||
const STREAM_REPLAY_GRACE: Duration = Duration::from_millis(1_200);
|
||||
// 额度拒绝后先给上游额度结算留出时间,再以受限时长同步 usage 快照。
|
||||
const QUOTA_FAILURE_REFRESH_DELAY: Duration = Duration::from_secs(2);
|
||||
const QUOTA_FAILURE_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
|
||||
pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
|
||||
|
||||
@@ -171,17 +176,7 @@ impl Provider for CodexProvider {
|
||||
return ProviderRequestObservation::default();
|
||||
};
|
||||
let (semantics, reasoning_effort) = encode_generate_request(request, "observability")
|
||||
.map(|mut encoded| {
|
||||
if let Some(previous) = decode_openai_session_state(request) {
|
||||
let mut input = previous
|
||||
.transcript
|
||||
.iter()
|
||||
.map(OpenAiReplayItem::value)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
input.extend(encoded.input().iter().cloned());
|
||||
encoded.set_input(input);
|
||||
}
|
||||
.map(|encoded| {
|
||||
let semantics = encoded.semantics();
|
||||
let reasoning_effort = semantics.reasoning_effort.clone();
|
||||
(semantics, reasoning_effort)
|
||||
@@ -261,7 +256,6 @@ impl Provider for CodexProvider {
|
||||
let session_affinity_key = derive_codex_session_affinity_key(&upstream_request);
|
||||
let cyber_policy_session_key =
|
||||
derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref());
|
||||
let request_input = upstream_request.input().to_vec();
|
||||
let transport = selected_transport(&upstream_request);
|
||||
apply_transport(&mut upstream_request, transport);
|
||||
|
||||
@@ -293,11 +287,8 @@ impl Provider for CodexProvider {
|
||||
) || previous_session.as_ref().is_some_and(|state| {
|
||||
state.continuation_scope == OpenAiContinuationScope::ReplayRequired
|
||||
});
|
||||
if replay_previous_response && let Some(state) = previous_session.as_ref() {
|
||||
let mut input = replay_input_for_account(state, lease.account_id().as_str());
|
||||
input.reserve(request_input.len());
|
||||
input.extend(request_input.iter().cloned());
|
||||
upstream_request.set_input(input);
|
||||
if replay_previous_response && previous_session.is_some() {
|
||||
// Codex 客户端 input 是完整历史;恢复时只解除上游续链绑定,避免重复叠加代理副本。
|
||||
upstream_request.set_previous_response_id(None);
|
||||
upstream_request.previous_response_scope = None;
|
||||
upstream_request.turn_state = None;
|
||||
@@ -363,8 +354,6 @@ impl Provider for CodexProvider {
|
||||
let response_store = upstream_request.store();
|
||||
let session_capture =
|
||||
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
|
||||
previous: previous_session,
|
||||
request_input,
|
||||
account_id: lease.account_id().as_str().to_owned(),
|
||||
conversation_id: upstream_request.local_conversation_id.clone(),
|
||||
turn_state: upstream_request.turn_state.clone(),
|
||||
@@ -419,7 +408,6 @@ struct OpenAiSessionState {
|
||||
#[serde(default)]
|
||||
turn_state: Option<String>,
|
||||
continuation_scope: OpenAiContinuationScope,
|
||||
transcript: Vec<OpenAiReplayItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -430,27 +418,7 @@ enum OpenAiContinuationScope {
|
||||
ReplayRequired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum OpenAiReplayItem {
|
||||
ClientInput(Value),
|
||||
SanitizedOutput(Value),
|
||||
AccountOutput { account_id: String, item: Value },
|
||||
}
|
||||
|
||||
impl OpenAiReplayItem {
|
||||
fn value(&self) -> &Value {
|
||||
match self {
|
||||
Self::ClientInput(value)
|
||||
| Self::SanitizedOutput(value)
|
||||
| Self::AccountOutput { item: value, .. } => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAiSessionCapture {
|
||||
previous: Option<OpenAiSessionState>,
|
||||
request_input: Vec<Value>,
|
||||
account_id: String,
|
||||
conversation_id: Option<String>,
|
||||
turn_state: Option<String>,
|
||||
@@ -483,33 +451,15 @@ fn attach_openai_session_update(
|
||||
events: &mut [ProviderEvent],
|
||||
capture: &mut Option<OpenAiSessionCapture>,
|
||||
) {
|
||||
let Some((terminal_index, output)) = events.iter().enumerate().find_map(|(index, event)| {
|
||||
terminal_response_output(event).map(|output| (index, output.to_vec()))
|
||||
}) else {
|
||||
let Some(terminal_index) = events
|
||||
.iter()
|
||||
.position(|event| terminal_response_output(event).is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(capture) = capture.take() else {
|
||||
return;
|
||||
};
|
||||
let mut transcript = capture
|
||||
.previous
|
||||
.map(|state| state.transcript)
|
||||
.unwrap_or_default();
|
||||
project_transcript_to_account(&mut transcript, &capture.account_id);
|
||||
transcript.extend(
|
||||
capture
|
||||
.request_input
|
||||
.into_iter()
|
||||
.map(OpenAiReplayItem::ClientInput),
|
||||
);
|
||||
transcript.extend(
|
||||
output
|
||||
.into_iter()
|
||||
.map(|item| OpenAiReplayItem::AccountOutput {
|
||||
account_id: capture.account_id.clone(),
|
||||
item,
|
||||
}),
|
||||
);
|
||||
let Some(continuation_scope) = capture.continuation_scope else {
|
||||
return;
|
||||
};
|
||||
@@ -518,7 +468,6 @@ fn attach_openai_session_update(
|
||||
conversation_id: capture.conversation_id,
|
||||
turn_state: capture.turn_state,
|
||||
continuation_scope,
|
||||
transcript,
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
@@ -546,47 +495,6 @@ fn terminal_response_output(event: &ProviderEvent) -> Option<&[Value]> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn project_transcript_to_account(transcript: &mut Vec<OpenAiReplayItem>, account_id: &str) {
|
||||
*transcript = transcript
|
||||
.drain(..)
|
||||
.filter_map(|item| match item {
|
||||
OpenAiReplayItem::AccountOutput {
|
||||
account_id: owner,
|
||||
item,
|
||||
} if owner != account_id => {
|
||||
sanitize_cross_account_item(item).map(OpenAiReplayItem::SanitizedOutput)
|
||||
}
|
||||
item => Some(item),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn replay_input_for_account(state: &OpenAiSessionState, account_id: &str) -> Vec<Value> {
|
||||
state
|
||||
.transcript
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
OpenAiReplayItem::ClientInput(value) | OpenAiReplayItem::SanitizedOutput(value) => {
|
||||
Some(value.clone())
|
||||
}
|
||||
OpenAiReplayItem::AccountOutput {
|
||||
account_id: owner,
|
||||
item,
|
||||
} if owner == account_id => Some(without_output_id(item.clone())),
|
||||
OpenAiReplayItem::AccountOutput { item, .. } => {
|
||||
sanitize_cross_account_item(item.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn without_output_id(mut item: Value) -> Value {
|
||||
if let Value::Object(object) = &mut item {
|
||||
object.remove("id");
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
enum CodexHandshakeAttemptError {
|
||||
Client(CodexClientError),
|
||||
Cancelled,
|
||||
@@ -679,8 +587,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
lease.escape_reason(),
|
||||
lease.account_switch(),
|
||||
);
|
||||
let stream_commit_policy =
|
||||
StreamCommitPolicy::for_continuation(context.continuation_attempt());
|
||||
let mut response = create_response_attempt(
|
||||
&client,
|
||||
&request,
|
||||
@@ -826,12 +732,13 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str())
|
||||
.with_requested_service_tier(request.service_tier())
|
||||
.with_raw_sse_passthrough();
|
||||
let mut pre_commit_events = PreCommitClientEvents::new(stream_commit_policy);
|
||||
let mut pre_commit_events = PreCommitClientEvents::new();
|
||||
loop {
|
||||
let Some(stream_deadline) = remaining(context.deadline()) else {
|
||||
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
|
||||
return;
|
||||
};
|
||||
let replay_grace_deadline = pre_commit_events.replay_grace_deadline();
|
||||
let next = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => Err(MappedProviderFailure::plain(provider_error(
|
||||
@@ -842,14 +749,21 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
_ = wait_for_replay_grace(replay_grace_deadline) => Ok(PreCommitPoll::GraceElapsed),
|
||||
chunk = body.next() => match chunk {
|
||||
Some(Ok(chunk)) => Ok(Some(chunk)),
|
||||
Some(Ok(chunk)) => Ok(PreCommitPoll::Upstream(Some(chunk))),
|
||||
Some(Err(error)) => Err(map_stream_error(error)),
|
||||
None => Ok(None),
|
||||
None => Ok(PreCommitPoll::Upstream(None)),
|
||||
},
|
||||
};
|
||||
let next = match next {
|
||||
Ok(next) => next,
|
||||
Ok(PreCommitPoll::Upstream(next)) => next,
|
||||
Ok(PreCommitPoll::GraceElapsed) => {
|
||||
for event in pre_commit_events.commit_pending() {
|
||||
yield event;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(failure) => {
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
if !updates.is_empty() {
|
||||
@@ -1755,7 +1669,10 @@ fn map_request_error(error: CodexRequestEncodeError) -> ProviderError {
|
||||
fn map_selection_error(error: CredentialSelectionError) -> ProviderError {
|
||||
match error {
|
||||
CredentialSelectionError::CapacityUnavailable { retry_after } => {
|
||||
let error = provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent);
|
||||
let error = provider_error(
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
);
|
||||
match retry_after {
|
||||
Some(retry) => error.with_retry_after(retry),
|
||||
None => error,
|
||||
@@ -1768,9 +1685,10 @@ fn map_selection_error(error: CredentialSelectionError) -> ProviderError {
|
||||
CredentialSelectionError::InvalidCredential
|
||||
| CredentialSelectionError::Store
|
||||
| CredentialSelectionError::Coordinator
|
||||
| CredentialSelectionError::CookiePolicy => {
|
||||
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
}
|
||||
| CredentialSelectionError::CookiePolicy => provider_error(
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,54 +1716,37 @@ impl MappedProviderFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 2.x 一致的流提交策略:普通请求优先首事件,续写/重放优先可恢复性。
|
||||
#[derive(Clone, Copy)]
|
||||
enum StreamCommitPolicy {
|
||||
FirstForwardableEvent,
|
||||
UntilOutputOrTerminal,
|
||||
enum PreCommitPoll<T> {
|
||||
Upstream(T),
|
||||
GraceElapsed,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
async fn wait_for_replay_grace(deadline: Option<Instant>) {
|
||||
if let Some(deadline) = deadline {
|
||||
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交边界前的上游事件预取。
|
||||
///
|
||||
/// 原始 chunk 计数而不是重编码后的 event 大小。64 KiB 只限定无感换号窗口;达到
|
||||
/// 阈值会提交已缓存 wire,绝不能因为网关私有资源规则伪造上游协议失败。一旦提交,
|
||||
/// 后续事件不再具备无痕重放资格。
|
||||
/// 原始 chunk 计数而不是重编码后的 event 大小。时间与 64 KiB 共同限定无感换号
|
||||
/// 窗口;任一边界到达都会提交已缓存 wire,不能因网关私有资源规则伪造上游协议
|
||||
/// 失败。一旦提交,后续事件不再具备无痕重放资格。
|
||||
struct PreCommitClientEvents {
|
||||
policy: StreamCommitPolicy,
|
||||
pending: Vec<ProviderEvent>,
|
||||
prefetched_bytes: usize,
|
||||
replay_grace_deadline: Option<Instant>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl PreCommitClientEvents {
|
||||
const fn new(policy: StreamCommitPolicy) -> Self {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
policy,
|
||||
pending: Vec::new(),
|
||||
prefetched_bytes: 0,
|
||||
replay_grace_deadline: None,
|
||||
committed: false,
|
||||
}
|
||||
}
|
||||
@@ -1865,13 +1766,17 @@ impl PreCommitClientEvents {
|
||||
if self.committed {
|
||||
return incoming;
|
||||
}
|
||||
let boundary_reached = self
|
||||
.policy
|
||||
.boundary_reached(&incoming, timing_signals, completed);
|
||||
let starts_replay_grace = incoming.iter().any(ProviderEvent::has_client_event);
|
||||
self.pending.extend(incoming);
|
||||
if boundary_reached || self.prefetched_bytes > MAX_STREAM_PREFETCH_BYTES {
|
||||
if timing_signals.semantic_output
|
||||
|| completed
|
||||
|| self.prefetched_bytes > MAX_STREAM_PREFETCH_BYTES
|
||||
{
|
||||
return self.commit_pending();
|
||||
}
|
||||
if starts_replay_grace && self.replay_grace_deadline.is_none() {
|
||||
self.replay_grace_deadline = Instant::now().checked_add(STREAM_REPLAY_GRACE);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -1900,9 +1805,14 @@ impl PreCommitClientEvents {
|
||||
fn take_for_failure(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
|
||||
self.pending.extend(incoming);
|
||||
self.prefetched_bytes = 0;
|
||||
self.replay_grace_deadline = None;
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
|
||||
const fn replay_grace_deadline(&self) -> Option<Instant> {
|
||||
self.replay_grace_deadline
|
||||
}
|
||||
|
||||
const fn is_committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
@@ -1910,6 +1820,7 @@ impl PreCommitClientEvents {
|
||||
fn commit_pending(&mut self) -> Vec<ProviderEvent> {
|
||||
self.committed = true;
|
||||
self.prefetched_bytes = 0;
|
||||
self.replay_grace_deadline = None;
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
@@ -1937,7 +1848,7 @@ impl ReplayBoundary {
|
||||
struct OpenAiFailureContext<'a> {
|
||||
client: &'a CodexBackendClient,
|
||||
selector: &'a CodexCredentialSelector,
|
||||
quota: &'a CodexCredentialQuotaService,
|
||||
quota: &'a Arc<CodexCredentialQuotaService>,
|
||||
response_origin: &'a Url,
|
||||
cyber_policy_scope: Option<&'a CodexCyberPolicyScope>,
|
||||
allows_account_state_mutation: bool,
|
||||
@@ -1952,6 +1863,10 @@ async fn apply_failure(
|
||||
return;
|
||||
}
|
||||
synchronize_passive_quota(context.quota, account, &failure.rate_limit_headers).await;
|
||||
let needs_authoritative_quota_refresh = matches!(
|
||||
failure.account_failure,
|
||||
Some(CodexAccountFailure::QuotaExhausted | CodexAccountFailure::UsageLimitExhausted { .. })
|
||||
);
|
||||
if failure.cyber_policy_failure {
|
||||
context
|
||||
.selector
|
||||
@@ -1975,6 +1890,9 @@ async fn apply_failure(
|
||||
);
|
||||
}
|
||||
}
|
||||
if needs_authoritative_quota_refresh {
|
||||
schedule_authoritative_quota_refresh_after_failure(context.quota, account);
|
||||
}
|
||||
if failure.capture_response_cookies
|
||||
&& !failure.set_cookie_headers.is_empty()
|
||||
&& let Err(error) = context
|
||||
@@ -1994,6 +1912,42 @@ async fn apply_failure(
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_authoritative_quota_refresh_after_failure(
|
||||
quota: &Arc<CodexCredentialQuotaService>,
|
||||
account: &ProviderAccount,
|
||||
) {
|
||||
// 限额错误可以确认账号状态,但 Responses 事件中的 used_percent 可能仍停在上一结算点。
|
||||
// usage 快照在后台补齐展示基线,不得撤销同一轮真实失败,也不能阻塞原始响应。
|
||||
// 先等待 2 秒让上游结算,再查询;5 秒仅限制实际查询自身。
|
||||
let quota = Arc::clone(quota);
|
||||
let account_id = account.id().clone();
|
||||
drop(tokio::spawn(async move {
|
||||
tokio::time::sleep(QUOTA_FAILURE_REFRESH_DELAY).await;
|
||||
match tokio::time::timeout(
|
||||
QUOTA_FAILURE_REFRESH_TIMEOUT,
|
||||
quota.refresh_account_after_failure(&account_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
error = %error,
|
||||
"OpenAI authoritative quota synchronization after quota rejection failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
timeout_ms = QUOTA_FAILURE_REFRESH_TIMEOUT.as_millis(),
|
||||
"OpenAI authoritative quota synchronization after quota rejection timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
|
||||
map_client_error(error, UpstreamSendState::Ambiguous, true)
|
||||
}
|
||||
@@ -2015,7 +1969,7 @@ fn stream_transport_allows_pre_delivery_retry(error: &CodexClientError) -> bool
|
||||
CodexWebSocketExchangeError::Transport(_)
|
||||
| CodexWebSocketExchangeError::PostSendAmbiguous { .. }
|
||||
| CodexWebSocketExchangeError::SendTimeout { .. }
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal(_)
|
||||
| CodexWebSocketExchangeError::ReceiveIdleTimeout { .. }
|
||||
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. }
|
||||
| CodexWebSocketExchangeError::InitialEventTimeout { .. }
|
||||
@@ -2103,10 +2057,19 @@ fn map_client_error(
|
||||
send_state,
|
||||
))
|
||||
}
|
||||
CodexClientError::WebSocket(error) => MappedProviderFailure::plain(provider_error(
|
||||
websocket_error_kind(&error),
|
||||
websocket_send_state(&error),
|
||||
)),
|
||||
CodexClientError::WebSocket(error) => {
|
||||
let client_visible_error = websocket_client_visible_error(&error);
|
||||
let mut failure = MappedProviderFailure::plain(provider_error(
|
||||
websocket_error_kind(&error),
|
||||
websocket_send_state(&error),
|
||||
));
|
||||
if let Some(client_visible_error) = client_visible_error {
|
||||
failure.error = failure
|
||||
.error
|
||||
.with_client_visible_upstream_error(client_visible_error);
|
||||
}
|
||||
failure
|
||||
}
|
||||
};
|
||||
if let Some(continuation_failure) = continuation_failure {
|
||||
failure.error = failure
|
||||
@@ -2117,6 +2080,21 @@ fn map_client_error(
|
||||
failure
|
||||
}
|
||||
|
||||
fn websocket_client_visible_error(
|
||||
error: &CodexWebSocketExchangeError,
|
||||
) -> Option<ClientVisibleUpstreamError> {
|
||||
let close = error.close_before_terminal()?;
|
||||
let message = close
|
||||
.reason()
|
||||
.filter(|reason| !reason.is_empty())
|
||||
.map_or_else(|| close.to_string(), str::to_owned);
|
||||
Some(ClientVisibleUpstreamError::new(
|
||||
message,
|
||||
close.code().map(|code| code.to_string()),
|
||||
Some("websocket_close_error".to_owned()),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_upstream_failure(
|
||||
mut failure: CodexUpstreamFailure,
|
||||
observation: Option<ProviderResponseObservation>,
|
||||
@@ -2285,7 +2263,7 @@ const fn websocket_send_state(error: &CodexWebSocketExchangeError) -> UpstreamSe
|
||||
CodexWebSocketExchangeError::Transport(_)
|
||||
| CodexWebSocketExchangeError::PostSendAmbiguous { .. }
|
||||
| CodexWebSocketExchangeError::SendTimeout { .. }
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal(_)
|
||||
| CodexWebSocketExchangeError::ReceiveIdleTimeout { .. }
|
||||
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. }
|
||||
| CodexWebSocketExchangeError::InitialEventTimeout { .. } => UpstreamSendState::Ambiguous,
|
||||
@@ -2312,7 +2290,7 @@ const fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> ProviderEr
|
||||
CodexWebSocketExchangeError::Transport(_)
|
||||
| CodexWebSocketExchangeError::Connect(_)
|
||||
| CodexWebSocketExchangeError::PostSendAmbiguous { .. }
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal
|
||||
| CodexWebSocketExchangeError::ClosedBeforeTerminal(_)
|
||||
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
|
||||
ProviderErrorKind::Transport
|
||||
}
|
||||
|
||||
@@ -477,6 +477,7 @@ pub enum CodexTransportDecision {
|
||||
Http2BreakerOpen,
|
||||
Http2PoolUnavailable,
|
||||
Http2PreSendFailure,
|
||||
Http2PreDeliveryFailure,
|
||||
}
|
||||
|
||||
impl CodexTransportDecision {
|
||||
@@ -491,6 +492,7 @@ impl CodexTransportDecision {
|
||||
Self::Http2BreakerOpen => "http2_breaker_open",
|
||||
Self::Http2PoolUnavailable => "http2_pool_unavailable",
|
||||
Self::Http2PreSendFailure => "http2_ws_pre_send_failure",
|
||||
Self::Http2PreDeliveryFailure => "http2_ws_pre_delivery_failure",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,11 @@ use crate::transport::{
|
||||
},
|
||||
response_meta,
|
||||
websocket::{
|
||||
CodexWebSocketConnection, CodexWebSocketPool, CodexWebSocketPoolKey,
|
||||
DEFAULT_INITIAL_EVENT_TIMEOUT, WEBSOCKET_FAST_PATH_BUDGET, WebSocketOriginBreaker,
|
||||
WebSocketPoolDecision, execute_prepared_response_create_request_stream,
|
||||
post_send_ambiguous, prepare_response_create_request_with_pool, websocket_audit_dir,
|
||||
CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketPool,
|
||||
CodexWebSocketPoolKey, CodexWebSocketStreamingExchange, DEFAULT_INITIAL_EVENT_TIMEOUT,
|
||||
WEBSOCKET_FAST_PATH_BUDGET, WebSocketOriginBreaker, WebSocketPoolDecision,
|
||||
execute_prepared_response_create_request_stream, post_send_ambiguous,
|
||||
prepare_response_create_request_with_pool, websocket_audit_dir,
|
||||
write_websocket_audit_artifact_from_env,
|
||||
},
|
||||
};
|
||||
@@ -308,7 +309,7 @@ impl CodexBackendClient {
|
||||
route,
|
||||
metrics,
|
||||
} = prepared;
|
||||
let result = match route {
|
||||
match route {
|
||||
PreparedResponseRoute::Http => self
|
||||
.create_response_stream_http_sse(request, context)
|
||||
.await
|
||||
@@ -317,50 +318,124 @@ impl CodexBackendClient {
|
||||
response
|
||||
}),
|
||||
PreparedResponseRoute::WebSocket(route) => {
|
||||
let PreparedWebSocketRoute { request, prepared } = *route;
|
||||
execute_prepared_response_create_request_stream(&request, prepared)
|
||||
.await
|
||||
.map_err(websocket_exchange_error_to_client_error)
|
||||
.map(|exchange| {
|
||||
tracing::info!(
|
||||
request_id = %context.request_id,
|
||||
websocket_connection_id = %exchange.websocket_connection_id,
|
||||
ws_pool = exchange.pool_decision.map_or("unpooled", WebSocketPoolDecision::kind),
|
||||
"WebSocket response stream established"
|
||||
);
|
||||
CodexBackendStreamingResponse {
|
||||
body: Box::pin(
|
||||
exchange
|
||||
.body
|
||||
.map_err(post_send_ambiguous)
|
||||
.map_err(websocket_exchange_error_to_client_error),
|
||||
),
|
||||
transport: CodexBackendTransport::WebSocket,
|
||||
turn_state: exchange.turn_state,
|
||||
set_cookie_headers: exchange.set_cookie_headers,
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
rate_limit_header_updates: Some(exchange.rate_limit_header_updates),
|
||||
turn_state_update: Some(exchange.turn_state_update),
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
diagnostics: exchange.diagnostics,
|
||||
response_metadata: exchange.response_metadata,
|
||||
transport_metrics: metrics,
|
||||
connection_local_continuation: exchange.connection_local_continuation,
|
||||
}
|
||||
})
|
||||
let PreparedWebSocketRoute {
|
||||
request: websocket_request,
|
||||
prepared,
|
||||
} = *route;
|
||||
let delivery_wait_started_at = Instant::now();
|
||||
let mut exchange = match execute_prepared_response_create_request_stream(
|
||||
&websocket_request,
|
||||
prepared,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(exchange) => exchange,
|
||||
Err(error)
|
||||
if requirement.allows_pre_delivery_http_fallback()
|
||||
&& error.allows_pre_delivery_http_fallback() =>
|
||||
{
|
||||
return self
|
||||
.fallback_to_http_before_websocket_delivery(
|
||||
request,
|
||||
context,
|
||||
requirement,
|
||||
metrics,
|
||||
delivery_wait_started_at,
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => return Err(websocket_exchange_error_to_client_error(error)),
|
||||
};
|
||||
if requirement.allows_pre_delivery_http_fallback()
|
||||
&& let Err(error) = await_websocket_delivery_boundary(&mut exchange).await
|
||||
{
|
||||
if error.allows_pre_delivery_http_fallback() {
|
||||
return self
|
||||
.fallback_to_http_before_websocket_delivery(
|
||||
request,
|
||||
context,
|
||||
requirement,
|
||||
metrics,
|
||||
delivery_wait_started_at,
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(websocket_exchange_error_to_client_error(
|
||||
post_send_ambiguous(error),
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
request_id = %context.request_id,
|
||||
websocket_connection_id = %exchange.websocket_connection_id,
|
||||
ws_pool = exchange.pool_decision.map_or("unpooled", WebSocketPoolDecision::kind),
|
||||
"WebSocket response stream established"
|
||||
);
|
||||
Ok(CodexBackendStreamingResponse {
|
||||
body: Box::pin(
|
||||
exchange
|
||||
.body
|
||||
.map_err(post_send_ambiguous)
|
||||
.map_err(websocket_exchange_error_to_client_error),
|
||||
),
|
||||
transport: CodexBackendTransport::WebSocket,
|
||||
turn_state: exchange.turn_state,
|
||||
set_cookie_headers: exchange.set_cookie_headers,
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
rate_limit_header_updates: Some(exchange.rate_limit_header_updates),
|
||||
turn_state_update: Some(exchange.turn_state_update),
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
diagnostics: exchange.diagnostics,
|
||||
response_metadata: exchange.response_metadata,
|
||||
transport_metrics: metrics,
|
||||
connection_local_continuation: exchange.connection_local_continuation,
|
||||
})
|
||||
}
|
||||
};
|
||||
result.inspect_err(|error| {
|
||||
}
|
||||
.inspect_err(|error| {
|
||||
tracing::warn!(
|
||||
request_id = %context.request_id,
|
||||
transport_requirement = requirement.as_str(),
|
||||
failure_phase = "post_send_or_explicit_response",
|
||||
error = %error,
|
||||
"Responses stream transport failed after preparation; automatic fallback is disabled"
|
||||
"Responses stream transport failed after preparation"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
async fn fallback_to_http_before_websocket_delivery(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
requirement: TransportRequirement,
|
||||
mut metrics: CodexTransportMetrics,
|
||||
delivery_wait_started_at: Instant,
|
||||
error: CodexWebSocketExchangeError,
|
||||
) -> CodexClientResult<CodexBackendStreamingResponse> {
|
||||
let decision_wait_ms = metrics
|
||||
.transport_decision_wait_ms
|
||||
.unwrap_or_default()
|
||||
.saturating_add(elapsed_duration_millis(delivery_wait_started_at.elapsed()));
|
||||
metrics.decision = Some(CodexTransportDecision::Http2PreDeliveryFailure);
|
||||
metrics.transport_decision_wait_ms = Some(decision_wait_ms);
|
||||
tracing::warn!(
|
||||
request_id = %context.request_id,
|
||||
account_id = context.account_id.unwrap_or_default(),
|
||||
transport_requirement = requirement.as_str(),
|
||||
transport_decision = CodexTransportDecision::Http2PreDeliveryFailure.as_str(),
|
||||
transport_decision_wait_ms = decision_wait_ms,
|
||||
error = %error,
|
||||
"WebSocket failed before first deliverable event; using same-account HTTP"
|
||||
);
|
||||
self.create_response_stream_http_sse(request, context)
|
||||
.await
|
||||
.map(|mut response| {
|
||||
merge_preparation_metrics(&mut response.transport_metrics, metrics);
|
||||
response
|
||||
})
|
||||
}
|
||||
|
||||
fn websocket_pool_key(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
@@ -563,6 +638,36 @@ impl CodexBackendClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_websocket_delivery_boundary(
|
||||
exchange: &mut CodexWebSocketStreamingExchange,
|
||||
) -> Result<(), CodexWebSocketExchangeError> {
|
||||
let mut prelude = Vec::new();
|
||||
loop {
|
||||
match exchange.body.next().await {
|
||||
Some(Ok(frame)) if is_websocket_lifecycle_prelude(&frame) => prelude.push(frame),
|
||||
Some(Ok(frame)) => {
|
||||
prelude.push(frame);
|
||||
let remaining =
|
||||
std::mem::replace(&mut exchange.body, Box::pin(futures::stream::empty()));
|
||||
exchange.body =
|
||||
Box::pin(futures::stream::iter(prelude.into_iter().map(Ok)).chain(remaining));
|
||||
return Ok(());
|
||||
}
|
||||
Some(Err(error)) => return Err(error),
|
||||
None => {
|
||||
return Err(CodexWebSocketExchangeError::closed_before_terminal(
|
||||
None, None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_websocket_lifecycle_prelude(frame: &[u8]) -> bool {
|
||||
frame.starts_with(b"event: response.created\n")
|
||||
|| frame.starts_with(b"event: response.in_progress\n")
|
||||
}
|
||||
|
||||
async fn read_model_catalog_body(response: ReqwestResponse) -> CodexClientResult<Vec<u8>> {
|
||||
if response
|
||||
.content_length()
|
||||
|
||||
@@ -139,6 +139,11 @@ impl TransportRequirement {
|
||||
)
|
||||
}
|
||||
|
||||
/// WebSocket 已发送 payload、但尚未交付业务事件时,是否允许同账号 HTTP 重放。
|
||||
pub fn allows_pre_delivery_http_fallback(self) -> bool {
|
||||
matches!(self, Self::NewChain)
|
||||
}
|
||||
|
||||
/// 用于审计与遥测的稳定名称。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -71,8 +71,8 @@ pub enum CodexWebSocketExchangeError {
|
||||
reason: PreviousResponseUnavailableReason,
|
||||
},
|
||||
/// 上游在 terminal 事件前关闭。
|
||||
#[error("websocket closed before terminal event")]
|
||||
ClosedBeforeTerminal,
|
||||
#[error("{0}")]
|
||||
ClosedBeforeTerminal(CodexWebSocketCloseError),
|
||||
/// 上游在指定时间内没有发送任何事件。
|
||||
#[error("websocket receive idle timeout after {timeout:?}")]
|
||||
ReceiveIdleTimeout {
|
||||
@@ -87,6 +87,9 @@ pub enum CodexWebSocketExchangeError {
|
||||
ReusedConnectionDiedBeforeFirstEvent {
|
||||
/// 底层失效原因。
|
||||
message: String,
|
||||
/// 原始 typed transport failure。
|
||||
#[source]
|
||||
source: Option<Box<CodexWebSocketExchangeError>>,
|
||||
},
|
||||
/// 建连并发送后,上游在配置时间内没有产生任何事件。
|
||||
#[error("websocket first upstream event not received within {timeout:?}")]
|
||||
@@ -96,6 +99,55 @@ pub enum CodexWebSocketExchangeError {
|
||||
},
|
||||
}
|
||||
|
||||
/// 上游在 terminal 事件前发送的 WebSocket close 信息。
|
||||
///
|
||||
/// close reason 只供原请求的客户端错误响应使用;`Debug` 与 `Display` 均不会输出它。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CodexWebSocketCloseError {
|
||||
code: Option<u16>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexWebSocketCloseError {
|
||||
pub(crate) fn new(code: Option<u16>, reason: Option<String>) -> Self {
|
||||
Self { code, reason }
|
||||
}
|
||||
|
||||
/// 返回上游 close code;没有 close frame 时为 `None`。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> Option<u16> {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 返回上游 close reason;仅可用于当前请求的客户端协议响应。
|
||||
#[must_use]
|
||||
pub fn reason(&self) -> Option<&str> {
|
||||
self.reason.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CodexWebSocketCloseError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CodexWebSocketCloseError")
|
||||
.field("code", &self.code)
|
||||
.field("reason", &self.reason.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CodexWebSocketCloseError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.code {
|
||||
Some(code) => write!(
|
||||
formatter,
|
||||
"websocket closed before terminal event (code {code})"
|
||||
),
|
||||
None => formatter.write_str("websocket closed before terminal event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket 上游错误帧载荷。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CodexWebSocketUpstreamError {
|
||||
@@ -140,6 +192,25 @@ impl fmt::Display for CodexWebSocketUpstreamError {
|
||||
}
|
||||
|
||||
impl CodexWebSocketExchangeError {
|
||||
pub(crate) fn closed_before_terminal(code: Option<u16>, reason: Option<String>) -> Self {
|
||||
Self::ClosedBeforeTerminal(CodexWebSocketCloseError::new(code, reason))
|
||||
}
|
||||
|
||||
pub(crate) fn close_before_terminal(&self) -> Option<&CodexWebSocketCloseError> {
|
||||
match self {
|
||||
Self::ClosedBeforeTerminal(close) => Some(close),
|
||||
Self::PostSendAmbiguous {
|
||||
source: Some(source),
|
||||
..
|
||||
}
|
||||
| Self::ReusedConnectionDiedBeforeFirstEvent {
|
||||
source: Some(source),
|
||||
..
|
||||
} => source.close_before_terminal(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn upstream(
|
||||
status_code: u16,
|
||||
retry_after_seconds: Option<u64>,
|
||||
@@ -173,4 +244,16 @@ impl CodexWebSocketExchangeError {
|
||||
| Self::ContinuationUnavailable { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 首个业务事件交付前,普通 WebSocket 失败可切到同账号 HTTP。
|
||||
pub(in crate::transport) fn allows_pre_delivery_http_fallback(&self) -> bool {
|
||||
match self {
|
||||
Self::Upstream(_) => false,
|
||||
Self::PostSendAmbiguous {
|
||||
source: Some(source),
|
||||
..
|
||||
} => source.allows_pre_delivery_http_fallback(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +40,20 @@ pub(super) fn reused_stream_receive_error(
|
||||
error: CodexWebSocketExchangeError,
|
||||
) -> CodexWebSocketExchangeError {
|
||||
match error {
|
||||
CodexWebSocketExchangeError::ClosedBeforeTerminal
|
||||
CodexWebSocketExchangeError::ClosedBeforeTerminal(_)
|
||||
| CodexWebSocketExchangeError::ReceiveIdleTimeout { .. }
|
||||
| CodexWebSocketExchangeError::Transport(_) => {
|
||||
reused_connection_died_before_first_event(&error)
|
||||
reused_connection_died_before_first_event(error)
|
||||
}
|
||||
error => error,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reused_connection_died_before_first_event(
|
||||
error: &CodexWebSocketExchangeError,
|
||||
error: CodexWebSocketExchangeError,
|
||||
) -> CodexWebSocketExchangeError {
|
||||
CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent {
|
||||
message: error.to_string(),
|
||||
source: Some(Box::new(error)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,14 +208,17 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
tungstenite::Message::Close(_) => {
|
||||
tungstenite::Message::Close(frame) => {
|
||||
discard_stream_websocket(
|
||||
websocket,
|
||||
pool_return,
|
||||
StreamWebSocketDiscardReason::UpstreamClosed,
|
||||
)
|
||||
.await;
|
||||
let error = CodexWebSocketExchangeError::ClosedBeforeTerminal;
|
||||
let error = CodexWebSocketExchangeError::closed_before_terminal(
|
||||
frame.as_ref().map(|frame| u16::from(frame.code)),
|
||||
frame.map(|frame| frame.reason.to_string()),
|
||||
);
|
||||
let error = if reused_connection {
|
||||
reused_stream_receive_error(error)
|
||||
} else {
|
||||
@@ -285,13 +288,19 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
|
||||
}
|
||||
}
|
||||
|
||||
let upstream_close = websocket
|
||||
.exit_reason()
|
||||
.and_then(|reason| reason.upstream_close().cloned());
|
||||
discard_stream_websocket(
|
||||
websocket,
|
||||
pool_return,
|
||||
StreamWebSocketDiscardReason::UpstreamClosed,
|
||||
)
|
||||
.await;
|
||||
let error = CodexWebSocketExchangeError::ClosedBeforeTerminal;
|
||||
let error = upstream_close.map_or_else(
|
||||
|| CodexWebSocketExchangeError::closed_before_terminal(None, None),
|
||||
CodexWebSocketExchangeError::ClosedBeforeTerminal,
|
||||
);
|
||||
let error = if reused_connection {
|
||||
reused_stream_receive_error(error)
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub use self::{
|
||||
WebSocketOriginBreaker, WebSocketOriginBreakerConfig, WebSocketOriginBreakerDecision,
|
||||
WebSocketOriginBreakerPermit,
|
||||
},
|
||||
error::{CodexWebSocketExchangeError, CodexWebSocketUpstreamError},
|
||||
error::{CodexWebSocketCloseError, CodexWebSocketExchangeError, CodexWebSocketUpstreamError},
|
||||
exchange::{
|
||||
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream,
|
||||
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate,
|
||||
|
||||
@@ -29,6 +29,8 @@ use tokio::{
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite::Message};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::CodexWebSocketCloseError;
|
||||
|
||||
/// 底层 tungstenite WebSocket 流。
|
||||
pub(crate) type RawWsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
@@ -69,13 +71,25 @@ const PUMP_MESSAGE_BUFFER: usize = 64;
|
||||
pub(crate) enum PumpExitReason {
|
||||
CommandChannelClosed,
|
||||
LocalClose,
|
||||
OutboundTransportError { message: String },
|
||||
UpstreamCloseFrame { frame: Option<String> },
|
||||
OutboundTransportError {
|
||||
message: String,
|
||||
},
|
||||
UpstreamCloseFrame {
|
||||
close: Option<CodexWebSocketCloseError>,
|
||||
},
|
||||
UpstreamEof,
|
||||
InboundTransportError { message: String },
|
||||
PongTimeout { timeout: Duration },
|
||||
LivenessTimeout { timeout: Duration },
|
||||
KeepaliveTransportError { message: String },
|
||||
InboundTransportError {
|
||||
message: String,
|
||||
},
|
||||
PongTimeout {
|
||||
timeout: Duration,
|
||||
},
|
||||
LivenessTimeout {
|
||||
timeout: Duration,
|
||||
},
|
||||
KeepaliveTransportError {
|
||||
message: String,
|
||||
},
|
||||
MessageReceiverClosed,
|
||||
}
|
||||
|
||||
@@ -100,7 +114,7 @@ impl PumpExitReason {
|
||||
Self::OutboundTransportError { message }
|
||||
| Self::InboundTransportError { message }
|
||||
| Self::KeepaliveTransportError { message } => Some(message.clone()),
|
||||
Self::UpstreamCloseFrame { frame } => frame.clone(),
|
||||
Self::UpstreamCloseFrame { close } => close.as_ref().map(ToString::to_string),
|
||||
Self::PongTimeout { timeout } | Self::LivenessTimeout { timeout } => {
|
||||
Some(format!("{timeout:?}"))
|
||||
}
|
||||
@@ -111,6 +125,13 @@ impl PumpExitReason {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn upstream_close(&self) -> Option<&CodexWebSocketCloseError> {
|
||||
match self {
|
||||
Self::UpstreamCloseFrame { close: Some(close) } => Some(close),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unexpected(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -336,7 +357,12 @@ async fn pump_loop(
|
||||
message => {
|
||||
let terminal_reason = match &message {
|
||||
Message::Close(frame) => Some(PumpExitReason::UpstreamCloseFrame {
|
||||
frame: frame.as_ref().map(|frame| format!("{frame:?}")),
|
||||
close: frame.as_ref().map(|frame| {
|
||||
CodexWebSocketCloseError::new(
|
||||
Some(u16::from(frame.code)),
|
||||
Some(frame.reason.to_string()),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ use gateway_admin::model::provider_credentials::{
|
||||
use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, OpaqueProviderData, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, QuotaObservation,
|
||||
AccountAvailability as CoreAccountAvailability, AccountStateChange, CredentialRevision,
|
||||
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore, QuotaObservation,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
use gateway_core::provider_ports::{
|
||||
@@ -503,6 +503,108 @@ async fn openai_admin_provider_projects_codex_additional_limit_as_the_primary_qu
|
||||
assert_eq!(monthly[0].used_percent, Some(2.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_admin_projects_confirmed_quota_exhaustion_as_full_without_mutating_raw_usage() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_admin_confirmed_exhaustion";
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
name: "admin confirmed exhaustion".to_owned(),
|
||||
secret: secret("admin-confirmed-exhaustion-access"),
|
||||
verified_account: profile("chatgpt-admin-confirmed-exhaustion"),
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account = store.account(account_id).expect("stored account");
|
||||
let reset_at = 1_900_000_000_u64;
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 86, "reset_at": reset_at}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("persist raw quota");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: CoreAccountAvailability::QuotaExhausted,
|
||||
reason: Some("usage_limit_exhausted".to_owned()),
|
||||
cooldown_until: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(reset_at)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
|
||||
let exhausted = bundle
|
||||
.admin_provider()
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account.id().clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.expect("project exhausted quota");
|
||||
|
||||
assert_eq!(exhausted.windows.len(), 1);
|
||||
assert_eq!(exhausted.windows[0].used_percent, Some(100.0));
|
||||
let raw = store
|
||||
.get_quotas(std::slice::from_ref(account.id()))
|
||||
.await
|
||||
.expect("read raw quota")
|
||||
.pop()
|
||||
.expect("raw quota");
|
||||
assert_eq!(
|
||||
raw.quota.expect("raw document").expose_to_provider()["rate_limit"]["primary_window"]["used_percent"],
|
||||
86
|
||||
);
|
||||
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: CoreAccountAvailability::Ready,
|
||||
reason: None,
|
||||
cooldown_until: None,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("recover account");
|
||||
let recovered = bundle
|
||||
.admin_provider()
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account.id().clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.expect("project recovered quota");
|
||||
|
||||
assert_eq!(recovered.windows[0].used_percent, Some(86.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -117,7 +117,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
64,
|
||||
8,
|
||||
20_000,
|
||||
5,
|
||||
15,
|
||||
true,
|
||||
true,
|
||||
"app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
|
||||
@@ -443,7 +443,7 @@ async fn passive_full_percent_with_explicit_allowance_keeps_account_ready() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_updates_account_state_from_provider_response() {
|
||||
async fn manual_quota_refresh_keeps_confirmed_exhaustion_until_the_recorded_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_quota_state";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -509,24 +509,57 @@ async fn manual_quota_refresh_updates_account_state_from_provider_response() {
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 20, "reset_at": 1_900_000_000}
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let recovered = service
|
||||
let stale = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh recovered quota");
|
||||
.expect("refresh contradictory full quota");
|
||||
|
||||
assert!(!recovered.fact().exhausted());
|
||||
assert!(!stale.fact().exhausted());
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("recovered account")
|
||||
.expect("account after contradictory quota")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"an unchanged usage window cannot override a confirmed quota failure"
|
||||
);
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
format!("Bearer token-{account_id}"),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 99, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let unchanged_reset = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh lower usage quota");
|
||||
|
||||
assert!(!unchanged_reset.fact().exhausted());
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after lower usage")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"a lower used_percent before the recorded reset is not quota recovery"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -755,7 +788,7 @@ async fn synchronize_without_accounts_is_a_noop_before_network_io() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_skips_fresh_ready_accounts() {
|
||||
async fn periodic_quota_synchronization_does_not_scan_stale_ready_accounts() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_ready").await;
|
||||
let account = store
|
||||
@@ -776,15 +809,16 @@ async fn periodic_quota_synchronization_skips_fresh_ready_accounts() {
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
// 正常账号即使缓存已旧,也只等待真实请求被动同步,不能被定时任务主动拉取。
|
||||
observed_at: Some(SystemTime::UNIX_EPOCH),
|
||||
})
|
||||
.await
|
||||
.expect("persist fresh quota");
|
||||
.expect("persist stale quota");
|
||||
|
||||
let summary = blocked_network_quota_service(&store)
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("fresh ready account must not query upstream");
|
||||
.expect("ready account must not query upstream");
|
||||
|
||||
assert_eq!(summary, CodexQuotaSyncSummary::default());
|
||||
}
|
||||
@@ -830,7 +864,13 @@ async fn periodic_quota_synchronization_repairs_cooldown_with_active_exhausted_q
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
@@ -939,72 +979,6 @@ async fn periodic_quota_synchronization_skips_ordinary_cooldown_without_exhauste
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_limits_stale_ready_account_scans() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
for index in 0..9 {
|
||||
let account_id = format!("acct_periodic_stale_ready_{index}");
|
||||
create_account(&store, &account_id).await;
|
||||
let account = store.account(&account_id).expect("created account");
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {"used_percent": 10, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::UNIX_EPOCH),
|
||||
})
|
||||
.await
|
||||
.expect("persist stale quota");
|
||||
}
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let summary = service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(summary.exhausted, 8);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_stale_ready_0")
|
||||
.expect("scanned account")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_stale_ready_8")
|
||||
.expect("deferred account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_attempts_quota_exhausted_accounts() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -1033,7 +1007,7 @@ async fn periodic_quota_synchronization_attempts_quota_exhausted_accounts() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_waits_for_quota_cooldown_deadline_with_grace() {
|
||||
async fn periodic_quota_synchronization_rechecks_exhausted_accounts_without_waiting_for_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_exhausted_cooldown").await;
|
||||
let account = store
|
||||
@@ -1045,38 +1019,29 @@ async fn periodic_quota_synchronization_waits_for_quota_cooldown_deadline_with_g
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("usage_limit_exhausted".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_sub(Duration::from_secs(29)),
|
||||
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(24 * 60 * 60)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted with recent cooldown");
|
||||
let service = blocked_network_quota_service(&store);
|
||||
.expect("mark account exhausted with a future reset");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let waiting = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("quota synchronization before grace");
|
||||
|
||||
assert_eq!(waiting, CodexQuotaSyncSummary::default());
|
||||
let current = store
|
||||
.account("acct_periodic_exhausted_cooldown")
|
||||
.expect("current account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: current.id().clone(),
|
||||
expected_revision: current.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("usage_limit_exhausted".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_sub(Duration::from_secs(31)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted past grace");
|
||||
|
||||
let due = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("quota synchronization after grace");
|
||||
// reset 仅用于展示/调度状态,不能推迟额度耗尽账号的定时复核。
|
||||
let due = service.synchronize().await.expect("quota synchronization");
|
||||
let repeated = service
|
||||
.synchronize()
|
||||
.await
|
||||
@@ -1086,6 +1051,170 @@ async fn periodic_quota_synchronization_waits_for_quota_cooldown_deadline_with_g
|
||||
assert_eq!(repeated, CodexQuotaSyncSummary::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_before_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_recovery_evidence";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let reset_at = 1_900_000_000;
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("seed post-failure quota snapshot");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("usage_limit_exhausted".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(24 * 60 * 60)),
|
||||
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(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let unchanged = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("unchanged quota recheck");
|
||||
|
||||
assert_eq!(unchanged.updated, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after unchanged snapshot")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
// 新 worker 实例模拟下一次 30 分钟复核,避免测试等待真实节流周期。
|
||||
let next_cycle = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let lower_percent = next_cycle
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("lower percent quota recheck");
|
||||
|
||||
assert_eq!(lower_percent.updated, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after lower percent snapshot")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"a provider percentage change is not sufficient to release a confirmed exhaustion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_reset_elapsed";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("usage_limit_exhausted".to_owned()),
|
||||
cooldown_until: SystemTime::now().checked_sub(Duration::from_secs(60)),
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted with an elapsed reset");
|
||||
let server = MockServer::start().await;
|
||||
let next_reset = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 1, "reset_at": next_reset}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
// 定时复核可提前执行,但只有已记录的 reset 到期后才释放额度耗尽状态。
|
||||
let summary = service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(summary.updated, 1);
|
||||
let current = store
|
||||
.account(account_id)
|
||||
.expect("account after elapsed reset");
|
||||
assert_eq!(current.availability(), AccountAvailability::Ready);
|
||||
assert_eq!(current.cooldown_until(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_throttles_the_same_exhausted_account() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -1113,91 +1242,7 @@ async fn periodic_quota_synchronization_throttles_the_same_exhausted_account() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_rechecks_an_exhausted_account_once_when_its_reset_is_due() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_reset_due").await;
|
||||
let account = store
|
||||
.account("acct_periodic_reset_due")
|
||||
.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");
|
||||
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": 1_900_000_000}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("persist future reset");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let waiting = service.synchronize().await.expect("initial cooldown wait");
|
||||
assert_eq!(waiting, CodexQuotaSyncSummary::default());
|
||||
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": 1}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("persist due reset");
|
||||
|
||||
let reset_recheck = service.synchronize().await.expect("reset recheck");
|
||||
let repeated = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("throttled reset recheck");
|
||||
|
||||
assert_eq!(reset_recheck.transient, 1);
|
||||
assert_eq!(repeated, CodexQuotaSyncSummary::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_reschedules_a_confirmed_new_reset() {
|
||||
async fn periodic_quota_synchronization_does_not_bypass_throttle_for_a_new_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_new_reset";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -1241,7 +1286,10 @@ async fn periodic_quota_synchronization_reschedules_a_confirmed_new_reset() {
|
||||
.account(account_id)
|
||||
.expect("account after first recheck")
|
||||
.cooldown_until();
|
||||
let second = service.synchronize().await.expect("new reset recheck");
|
||||
let second = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("throttled reset recheck");
|
||||
let repeated = service.synchronize().await.expect("same reset throttled");
|
||||
let request_count = server
|
||||
.received_requests()
|
||||
@@ -1260,9 +1308,9 @@ async fn periodic_quota_synchronization_reschedules_a_confirmed_new_reset() {
|
||||
(
|
||||
1,
|
||||
Some(confirmed_reset),
|
||||
1,
|
||||
0,
|
||||
CodexQuotaSyncSummary::default(),
|
||||
2,
|
||||
1,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, AccountFeedbackStats, ProviderAccountId, ProviderAccountStore as _,
|
||||
AccountAvailability, AccountFeedbackStats, OpaqueProviderData, ProviderAccountId,
|
||||
ProviderAccountStore as _, QuotaObservation,
|
||||
};
|
||||
use gateway_core::engine::provider::{Provider as _, ProviderRequest};
|
||||
use gateway_core::engine::{
|
||||
@@ -36,6 +37,8 @@ use tokio::{
|
||||
sync::oneshot,
|
||||
time::timeout,
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode};
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
@@ -43,6 +46,7 @@ use crate::support::{
|
||||
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
|
||||
account_policy, agent_identity_service_with_pool, catalog_cache, profile, secret,
|
||||
};
|
||||
use crate::transport::accept_codex_test_websocket;
|
||||
|
||||
const OFFICIAL_FIXTURE: &[u8] =
|
||||
include_bytes!("../transport/fixtures/official_models_snapshot.json");
|
||||
@@ -80,10 +84,36 @@ fn provider_with_base_url(store: &Arc<MemoryAccountStore>, base_url: String) ->
|
||||
provider_with_affinity_and_base_url(store, Arc::new(MemorySessionAffinity::default()), base_url)
|
||||
}
|
||||
|
||||
fn provider_with_leases(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
leases: Arc<TestLeaseCoordinator>,
|
||||
) -> CodexProvider {
|
||||
provider_with_affinity_and_base_url_and_leases(
|
||||
store,
|
||||
Arc::new(MemorySessionAffinity::default()),
|
||||
OFFICIAL_CODEX_BASE_URL.to_owned(),
|
||||
leases,
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_with_affinity_and_base_url(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
session_affinity: Arc<MemorySessionAffinity>,
|
||||
base_url: String,
|
||||
) -> CodexProvider {
|
||||
provider_with_affinity_and_base_url_and_leases(
|
||||
store,
|
||||
session_affinity,
|
||||
base_url,
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_with_affinity_and_base_url_and_leases(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
session_affinity: Arc<MemorySessionAffinity>,
|
||||
base_url: String,
|
||||
leases: Arc<TestLeaseCoordinator>,
|
||||
) -> CodexProvider {
|
||||
let profile = wire_profile();
|
||||
let http = reqwest::Client::builder()
|
||||
@@ -111,7 +141,7 @@ fn provider_with_affinity_and_base_url(
|
||||
let selector = Arc::new(CodexCredentialSelector::new(
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
store.repository(),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
leases,
|
||||
session_affinity,
|
||||
Arc::new(MemorySessionExclusions::default()),
|
||||
Arc::clone(&catalog),
|
||||
@@ -516,6 +546,130 @@ async fn generate_without_an_eligible_openai_account_fails_before_network_io() {
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_selection_error_preserves_classification_and_retry_after() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_capacity_busy").await;
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
*leases.busy.lock().expect("lease busy lock") = true;
|
||||
|
||||
let error = match provider_with_leases(&store, leases)
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_capacity_busy", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("busy account selection must fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
(error.kind(), error.send_state(), error.retry_after()),
|
||||
(
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
Some(Duration::from_millis(25)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selection_infrastructure_errors_have_a_distinct_classification() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store.fail_provider_listing();
|
||||
|
||||
let error = match provider(&store)
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_selection_store_failure", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("account store failure must fail selection"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
(error.kind(), error.send_state()),
|
||||
(
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_close_details_are_only_exposed_through_the_client_error() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_websocket_close").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind WebSocket listener");
|
||||
let base_url = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("listener address")
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.expect("accept WebSocket connection");
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _request = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("WebSocket request")
|
||||
.expect("valid WebSocket request");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "partial output"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send semantic WebSocket event");
|
||||
websocket
|
||||
.close(Some(CloseFrame {
|
||||
code: CloseCode::Size,
|
||||
reason: "message too big".into(),
|
||||
}))
|
||||
.await
|
||||
.expect("close WebSocket");
|
||||
});
|
||||
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_websocket_close", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare WebSocket provider stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("WebSocket close must surface a provider error"),
|
||||
}
|
||||
};
|
||||
server.await.expect("WebSocket server");
|
||||
|
||||
assert!(!format!("{error:?}").contains("message too big"));
|
||||
assert!(!error.to_string().contains("message too big"));
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Transport);
|
||||
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
|
||||
assert!(error.allows_pre_delivery_retry());
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("WebSocket close detail");
|
||||
assert_eq!(detail.message(), "message too big");
|
||||
assert_eq!(detail.code(), Some("1009"));
|
||||
assert_eq!(detail.error_type(), Some("websocket_close_error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_account_is_excluded_from_normal_scheduling() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -700,6 +854,109 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_replay_should_use_the_client_full_input_without_legacy_transcript() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_client_history").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(CAPTURE_COMPLETED_SSE),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client_input = json!([
|
||||
{"role": "user", "content": "complete client history"},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
{"role": "user", "content": "current turn"}
|
||||
]);
|
||||
let generation = GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), client_input.clone()),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
|
||||
)
|
||||
.with_provider_session_state(
|
||||
ProviderSessionState::new(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("account_id".to_owned(), json!("acct_client_history")),
|
||||
("conversation_id".to_owned(), json!("conversation")),
|
||||
("continuation_scope".to_owned(), json!("replay_required")),
|
||||
(
|
||||
"transcript".to_owned(),
|
||||
json!([{"client_input": {"role": "user", "content": "stale proxy copy"}}]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("provider session state"),
|
||||
);
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", Operation::Generate(generation)),
|
||||
context("req_client_history", CancellationToken::new())
|
||||
.with_continuation_attempt(ContinuationAttempt::ReplayAny),
|
||||
)
|
||||
.await
|
||||
.expect("prepare replay stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("replay response");
|
||||
}
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured replay request");
|
||||
let body: Value = serde_json::from_slice(&requests[0].body).expect("captured JSON body");
|
||||
|
||||
assert_eq!(body.get("input"), Some(&client_input));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_response_session_state_should_not_copy_the_conversation_transcript() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_bounded_session_state").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(CAPTURE_COMPLETED_SSE),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_bounded_session_state", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let mut session_payload = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("provider response");
|
||||
if let Some(update) = event.session_update() {
|
||||
session_payload = Some(update.payload().clone());
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!session_payload
|
||||
.expect("completed response session update")
|
||||
.contains_key("transcript")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_drops_only_account_bound_opaque_headers() {
|
||||
let protocol_context = Map::from_iter([(
|
||||
@@ -1006,16 +1263,50 @@ async fn response_failed_before_semantic_output_is_atomic_and_persists_quota_loc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_failure_marks_quota_exhausted_without_usage_probe() {
|
||||
async fn usage_limit_failure_returns_promptly_and_refreshes_the_authoritative_quota_snapshot() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_usage_limit_request_path";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 99, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("stale quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("seed stale passive quota");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header("authorization", format!("Bearer at-{account_id}")))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
// 生产环境会短暂返回互相矛盾的快照:数字已到 100%,但布尔位仍声称可用。
|
||||
// 后台同步应覆盖展示数据,不能据此撤销真实请求已经确认的额度耗尽。
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
// 验证后台 usage 同步不能把原始的额度错误响应拖到查询完成之后。
|
||||
.set_delay(Duration::from_millis(750))
|
||||
.set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
@@ -1040,13 +1331,17 @@ async fn usage_limit_failure_marks_quota_exhausted_without_usage_probe() {
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
let failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("usage-limit failure must surface a typed failure"),
|
||||
let failure = timeout(Duration::from_millis(300), async {
|
||||
loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("usage-limit failure must surface a typed failure"),
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.await
|
||||
.expect("usage refresh must not block the original quota failure");
|
||||
|
||||
assert_eq!(failure.kind(), ProviderErrorKind::QuotaExhausted);
|
||||
let account = store.account(account_id).expect("usage-limit account");
|
||||
@@ -1056,22 +1351,69 @@ async fn usage_limit_failure_marks_quota_exhausted_without_usage_probe() {
|
||||
.cooldown_until()
|
||||
.is_some_and(|until| until > SystemTime::now())
|
||||
);
|
||||
assert!(!store.has_quota(account_id));
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.url.path() == "/api/codex/usage")
|
||||
.count(),
|
||||
0
|
||||
0,
|
||||
"usage refresh must wait for the upstream quota settlement delay"
|
||||
);
|
||||
|
||||
let quota = timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let quota = store
|
||||
.get_quotas(&[account.id().clone()])
|
||||
.await
|
||||
.expect("read refreshed quota")
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|observation| observation.quota)
|
||||
.expect("authoritative quota snapshot");
|
||||
let quota = Value::Object(quota.into_inner());
|
||||
if quota
|
||||
.pointer("/rate_limit/primary_window/used_percent")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(100)
|
||||
{
|
||||
break quota;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("background usage refresh must replace the stale quota snapshot");
|
||||
assert_eq!(
|
||||
quota
|
||||
.pointer("/rate_limit/primary_window/used_percent")
|
||||
.and_then(Value::as_u64),
|
||||
Some(100)
|
||||
);
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.url.path() == "/api/codex/usage")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after stale full usage refresh")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"a contradictory full usage snapshot must not unlock a confirmed exhausted account"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_request_should_forward_created_before_later_failed_chunk() {
|
||||
async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
|
||||
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(
|
||||
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"
|
||||
@@ -1092,41 +1434,111 @@ async fn ordinary_request_should_forward_created_before_later_failed_chunk() {
|
||||
.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;
|
||||
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(), "response.created must remain replayable");
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
first_event.wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.created")
|
||||
}
|
||||
|
||||
let exposed = 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!(
|
||||
exposed.is_err(),
|
||||
"a structural event must not commit the downstream before a later 429"
|
||||
);
|
||||
|
||||
release.send(()).expect("release second upstream chunk");
|
||||
let mut later_wire_types = Vec::new();
|
||||
let failure = loop {
|
||||
let mut 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());
|
||||
}
|
||||
}
|
||||
Ok(event) => assert!(
|
||||
!event.has_client_event(),
|
||||
"failure attempt leaked downstream"
|
||||
),
|
||||
Err(error) => break error,
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(later_wire_types, vec!["response.failed"]);
|
||||
assert!(!failure.replay_is_safe());
|
||||
assert!(!failure.has_atomic_client_events());
|
||||
assert!(failure.replay_is_safe());
|
||||
assert_eq!(
|
||||
failure
|
||||
.take_atomic_client_events()
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event()?.event_type())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["response.created", "response.failed"]
|
||||
);
|
||||
server.await.expect("chunked SSE server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_request_should_bound_structural_event_replay_grace() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_bounded_replay_grace").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_bounded_grace\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\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_bounded_replay_grace", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
|
||||
let first_event = timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let event = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("provider stream must stay open")
|
||||
.expect("provider event");
|
||||
if event.has_client_event() {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("response.created must be released after the bounded grace period");
|
||||
|
||||
assert_eq!(
|
||||
first_event.wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.created")
|
||||
);
|
||||
release.send(()).expect("finish upstream response");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("clean upstream EOF");
|
||||
}
|
||||
server.await.expect("chunked SSE server");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
@@ -36,12 +36,14 @@ struct StoredAccount {
|
||||
account: ProviderAccount,
|
||||
credential: gateway_core::engine::credential::PlaintextCredential,
|
||||
quota: Option<QuotaObservation>,
|
||||
state_observed_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MemoryAccountStore {
|
||||
accounts: Mutex<BTreeMap<ProviderAccountId, StoredAccount>>,
|
||||
quota_reads: AtomicUsize,
|
||||
fail_provider_listing: AtomicBool,
|
||||
}
|
||||
|
||||
impl MemoryAccountStore {
|
||||
@@ -114,6 +116,10 @@ impl MemoryAccountStore {
|
||||
.get(&id)
|
||||
.is_some_and(|stored| stored.quota.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn fail_provider_listing(&self) {
|
||||
self.fail_provider_listing.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -129,6 +135,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
account: input.account,
|
||||
credential: input.credential,
|
||||
quota: None,
|
||||
state_observed_at: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -160,6 +167,9 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
) -> Result<Vec<ProviderAccount>, StoreError> {
|
||||
if self.fail_provider_listing.load(Ordering::SeqCst) {
|
||||
return Err(store_error(StoreErrorKind::Unavailable));
|
||||
}
|
||||
Ok(self
|
||||
.accounts
|
||||
.lock()
|
||||
@@ -260,6 +270,15 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
if stored.account.revision() != observation.expected_revision {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
if stored
|
||||
.quota
|
||||
.as_ref()
|
||||
.and_then(|quota| quota.observed_at)
|
||||
.zip(observation.observed_at)
|
||||
.is_some_and(|(current, incoming)| current > incoming)
|
||||
{
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
stored.quota = Some(observation);
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
@@ -272,6 +291,12 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
if stored.account.revision() != change.expected_revision {
|
||||
return Err(store_error(StoreErrorKind::Conflict));
|
||||
}
|
||||
if stored
|
||||
.state_observed_at
|
||||
.is_some_and(|current| current > change.observed_at)
|
||||
{
|
||||
return Err(store_error(StoreErrorKind::Conflict));
|
||||
}
|
||||
stored.account = rebuild_account(
|
||||
&stored.account,
|
||||
AccountRebuild {
|
||||
@@ -285,6 +310,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
profile: None,
|
||||
},
|
||||
);
|
||||
stored.state_observed_at = Some(change.observed_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use provider_openai::transport::{
|
||||
WebSocketOriginBreaker, WebSocketOriginBreakerConfig, WebSocketOriginBreakerDecision,
|
||||
},
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -913,14 +914,133 @@ async fn concurrent_same_key_should_singleflight_websocket_opening() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payload_send_failure_should_not_open_http_fallback() {
|
||||
async fn websocket_failure_before_first_delivery_should_use_same_account_http() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket_with(stream, |request, response| {
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer access-token")
|
||||
);
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("chatgpt-account")
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"sec-websocket-extensions",
|
||||
"permessage-deflate".parse().unwrap(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_abandoned_websocket",
|
||||
"model": "gpt-test"
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": {
|
||||
"id": "resp_abandoned_websocket",
|
||||
"model": "gpt-test"
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
websocket
|
||||
.close(Some(CloseFrame {
|
||||
code: CloseCode::Size,
|
||||
reason: "message too big".into(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (mut http, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut http).await;
|
||||
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
|
||||
assert_eq!(
|
||||
read_header_value(&request, "authorization"),
|
||||
Some("Bearer access-token")
|
||||
);
|
||||
assert_eq!(
|
||||
read_header_value(&request, "chatgpt-account-id"),
|
||||
Some("chatgpt-account")
|
||||
);
|
||||
write_completed_sse_response(&mut http).await;
|
||||
});
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
|
||||
|
||||
let response = backend
|
||||
.create_response(
|
||||
&new_chain_request("conversation-pre-delivery-fallback"),
|
||||
request_context("req_pre_delivery_fallback", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("pre-delivery WebSocket failure should use same-account HTTP");
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(response.transport, CodexBackendTransport::HttpSse);
|
||||
assert_eq!(
|
||||
response.transport_metrics.decision,
|
||||
Some(CodexTransportDecision::Http2PreDeliveryFailure)
|
||||
);
|
||||
assert!(response.body.contains("resp_http_fallback"));
|
||||
assert!(!response.body.contains("resp_abandoned_websocket"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_failure_after_first_delivery_should_not_open_http_fallback() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
websocket.close(None).await.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "delivered"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
websocket
|
||||
.close(Some(CloseFrame {
|
||||
code: CloseCode::Size,
|
||||
reason: "message too big after output".into(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
.await
|
||||
@@ -936,17 +1056,25 @@ async fn payload_send_failure_should_not_open_http_fallback() {
|
||||
|
||||
let error = backend
|
||||
.create_response(
|
||||
&new_chain_request("conversation-post-send"),
|
||||
request_context("req_post_send", Some("chatgpt-account")),
|
||||
&new_chain_request("conversation-post-delivery"),
|
||||
request_context("req_post_delivery", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect_err("post-send close must not be replayed");
|
||||
.expect_err("post-delivery close must not be replayed");
|
||||
server.await.unwrap();
|
||||
|
||||
std::assert_matches!(
|
||||
error,
|
||||
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous { .. })
|
||||
);
|
||||
let CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
|
||||
source: Some(source),
|
||||
..
|
||||
}) = error
|
||||
else {
|
||||
panic!("expected typed post-delivery WebSocket failure");
|
||||
};
|
||||
let CodexWebSocketExchangeError::ClosedBeforeTerminal(close) = *source else {
|
||||
panic!("expected upstream close frame source");
|
||||
};
|
||||
assert_eq!(close.code(), Some(1009));
|
||||
assert_eq!(close.reason(), Some("message too big after output"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -218,6 +218,17 @@ fn transport_should_prefer_websocket_when_requested_without_history() {
|
||||
assert!(transport_requirement(&request).allows_pre_send_http_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_chain_should_allow_pre_delivery_http_fallback() {
|
||||
let mut request = codex_request("gpt-test", "be brief", Vec::new());
|
||||
request.use_websocket = true;
|
||||
|
||||
assert!(
|
||||
transport_requirement(&request).allows_pre_delivery_http_fallback(),
|
||||
"new chains may follow the TS same-account fallback behavior"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_should_mark_unknown_previous_response_as_external() {
|
||||
let mut request = codex_request("gpt-test", "be brief", Vec::new());
|
||||
@@ -230,6 +241,17 @@ fn transport_should_mark_unknown_previous_response_as_external() {
|
||||
assert!(transport_requirement(&request).allows_pre_send_http_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_response_should_forbid_pre_delivery_http_fallback() {
|
||||
let mut request = codex_request("gpt-test", "be brief", Vec::new());
|
||||
request.set_previous_response_id(Some("resp_previous".to_owned()));
|
||||
|
||||
assert!(
|
||||
!transport_requirement(&request).allows_pre_delivery_http_fallback(),
|
||||
"a sent continuation must not be replayed on another transport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_should_allow_forced_http_sse() {
|
||||
let mut request = codex_request("gpt-test", "be brief", Vec::new());
|
||||
|
||||
@@ -649,7 +649,15 @@ async fn websocket_execute_response_create_request_should_reject_binary_event()
|
||||
.unwrap();
|
||||
websocket.close(None).await.unwrap();
|
||||
});
|
||||
let prepared = prepared_websocket_request(&format!("http://{addr}"));
|
||||
let mut request = codex_request("gpt-test", "be brief", Vec::new());
|
||||
request.set_previous_response_id(Some("resp_binary_previous".to_owned()));
|
||||
let prepared = CodexWebSocketConnection::responses_create_request(
|
||||
&format!("http://{addr}"),
|
||||
"dGhlIHNhbXBsZSBub25jZQ==",
|
||||
vec![("authorization".to_owned(), "Bearer access-token".to_owned())],
|
||||
&request,
|
||||
)
|
||||
.expect("prepare continuation WebSocket request");
|
||||
|
||||
let error = execute_response_create_request(&prepared)
|
||||
.await
|
||||
|
||||
@@ -469,7 +469,7 @@ impl GrokCredentialQuotaService {
|
||||
if quota_refresh_may_update_state(&loaded.account, ¤t.account)
|
||||
&& let Some(availability) = quota_refresh_availability(
|
||||
current.account.availability(),
|
||||
quota_is_exhausted(snapshot.billing()),
|
||||
authoritative_quota_exhaustion(snapshot.billing()),
|
||||
)
|
||||
{
|
||||
self.repository
|
||||
@@ -1329,6 +1329,13 @@ fn quota_is_exhausted(billing: &GrokBillingPresentation) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `None` means the billing document cannot prove either quota state.
|
||||
fn authoritative_quota_exhaustion(billing: &GrokBillingPresentation) -> Option<bool> {
|
||||
billing
|
||||
.has_authoritative_quota()
|
||||
.then(|| quota_is_exhausted(billing))
|
||||
}
|
||||
|
||||
fn quota_refresh_may_update_state(before: &ProviderAccount, current: &ProviderAccount) -> bool {
|
||||
current.enabled()
|
||||
&& current
|
||||
@@ -1341,22 +1348,26 @@ fn quota_refresh_may_update_state(before: &ProviderAccount, current: &ProviderAc
|
||||
|
||||
fn quota_refresh_availability(
|
||||
current: AccountAvailability,
|
||||
exhausted: bool,
|
||||
exhausted: Option<bool>,
|
||||
) -> Option<GrokCredentialAvailability> {
|
||||
match current {
|
||||
AccountAvailability::Invalid
|
||||
| AccountAvailability::Expired
|
||||
| AccountAvailability::Banned => None,
|
||||
AccountAvailability::QuotaExhausted => {
|
||||
(!exhausted).then_some(GrokCredentialAvailability::Ready)
|
||||
matches!(exhausted, Some(false)).then_some(GrokCredentialAvailability::Ready)
|
||||
}
|
||||
AccountAvailability::Cooldown | AccountAvailability::Unknown => {
|
||||
exhausted.map(|exhausted| {
|
||||
if exhausted {
|
||||
GrokCredentialAvailability::QuotaExhausted
|
||||
} else {
|
||||
GrokCredentialAvailability::Ready
|
||||
}
|
||||
})
|
||||
}
|
||||
AccountAvailability::Cooldown | AccountAvailability::Unknown => Some(if exhausted {
|
||||
GrokCredentialAvailability::QuotaExhausted
|
||||
} else {
|
||||
GrokCredentialAvailability::Ready
|
||||
}),
|
||||
AccountAvailability::Ready => {
|
||||
exhausted.then_some(GrokCredentialAvailability::QuotaExhausted)
|
||||
matches!(exhausted, Some(true)).then_some(GrokCredentialAvailability::QuotaExhausted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,17 +874,24 @@ async fn quota_projection_preserves_unknown_period_for_dynamic_duration_fallback
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weekly_period_without_reported_allowance_is_not_authoritative_quota() {
|
||||
let (_, repository) = repository_with_accounts(&[("free-quota", "subject-free-quota")]).await;
|
||||
async fn non_authoritative_quota_refresh_does_not_clear_existing_quota_exhaustion() {
|
||||
let (store, repository) =
|
||||
repository_with_accounts(&[("free-quota", "subject-free-quota")]).await;
|
||||
let id = account_id("free-quota");
|
||||
set_account_state(&store, &id, AccountAvailability::QuotaExhausted, None).await;
|
||||
let transport = QueueBillingTransport::success(
|
||||
br#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-07-15T00:00:00Z","end":"2026-07-22T00:00:00Z"},"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"prepaidBalance":{"val":0}}}"#,
|
||||
);
|
||||
let snapshot = crate::support::grok_quota_service(repository, transport)
|
||||
.refresh_account(&account_id("free-quota"))
|
||||
.refresh_account(&id)
|
||||
.await
|
||||
.expect("refresh Free quota");
|
||||
|
||||
assert!(!snapshot.billing().has_authoritative_quota());
|
||||
assert_eq!(
|
||||
store.account(&id).expect("account").availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
0554fed953bd1440b50bc4033dc8bad96b51e132c57c2ccf1553728a991d5cb2 0001_initial.sql
|
||||
0ae3945c02461347ab8dcfc64821027cd2366505778e0450d71bd82772c4e99d 0002_drop_opaque_response_id_index.sql
|
||||
e608ed89991f9d6317491714447a00a61ede90caca4fb030eb30695654afb664 0003_store_opaque_response_ids_as_bytes.sql
|
||||
df4e02cf2335733326c12429ced4cbd0b35b8a22b3fc24981c3c0549477d7c43 0004_add_model_request_service_tier.sql
|
||||
fe8f36913c6db7053148db450f05e008dab6cdb9ca8cdcfcb22d97ad9a87bf49 0005_allow_quota_exhausted_cooldown.sql
|
||||
a4681a5c040e80c5234e879293fc34972080e5251dfbc7c40e1374f8e4170a4c 0001_initial.sql
|
||||
|
||||
@@ -166,7 +166,14 @@ create table provider_accounts (
|
||||
)
|
||||
),
|
||||
constraint provider_accounts_cooldown_ck check (
|
||||
(availability = 'cooldown') = (cooldown_until is not null)
|
||||
(
|
||||
availability != 'cooldown'
|
||||
or cooldown_until is not null
|
||||
)
|
||||
and (
|
||||
cooldown_until is null
|
||||
or availability in ('cooldown', 'quota_exhausted')
|
||||
)
|
||||
),
|
||||
constraint provider_accounts_time_ck check (
|
||||
created_at <= updated_at
|
||||
@@ -208,6 +215,7 @@ create table model_requests (
|
||||
requested_model_id text not null,
|
||||
provider_kind text,
|
||||
upstream_model_id text,
|
||||
service_tier text,
|
||||
provider_account_id text,
|
||||
provider_account_ref text,
|
||||
upstream_transport text,
|
||||
@@ -220,9 +228,11 @@ create table model_requests (
|
||||
outcome text not null default 'running',
|
||||
client_status_code integer,
|
||||
upstream_status_code integer,
|
||||
client_response_id text,
|
||||
-- Provider response IDs are opaque JSON strings. Bytes preserve embedded NUL
|
||||
-- and avoid imposing PostgreSQL text-index limits on protocol handles.
|
||||
client_response_id bytea,
|
||||
upstream_request_id text,
|
||||
upstream_response_id text,
|
||||
upstream_response_id bytea,
|
||||
error_kind text,
|
||||
provider_error_code text,
|
||||
error_message text,
|
||||
@@ -321,6 +331,13 @@ create table model_requests (
|
||||
provider_observation_json is null
|
||||
or jsonb_typeof(provider_observation_json) = 'object'
|
||||
),
|
||||
constraint model_requests_service_tier_ck check (
|
||||
service_tier is null
|
||||
or (
|
||||
octet_length(service_tier) between 1 and 64
|
||||
and service_tier !~ '[[:cntrl:]]'
|
||||
)
|
||||
),
|
||||
constraint model_requests_cost_ck check (
|
||||
cost_source in ('provider_reported', 'calculated', 'unavailable')
|
||||
and (
|
||||
@@ -414,10 +431,6 @@ create index model_requests_running_deadline_idx
|
||||
create index model_requests_retention_idx
|
||||
on model_requests (completed_at)
|
||||
where outcome <> 'running';
|
||||
create unique index model_requests_client_response_uq
|
||||
on model_requests (client_response_id)
|
||||
where client_response_id is not null;
|
||||
|
||||
create table ops_events (
|
||||
id text primary key,
|
||||
model_request_id text,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Provider response IDs are opaque protocol values, not relational identifiers.
|
||||
-- Indexing the raw text makes PostgreSQL reject otherwise valid long handles.
|
||||
drop index model_requests_client_response_uq;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Response IDs are opaque JSON strings. PostgreSQL text rejects NUL even though JSON accepts it.
|
||||
-- Store their UTF-8 bytes without an index; Rust decodes them only at the audit/API boundary.
|
||||
alter table model_requests
|
||||
alter column client_response_id type bytea
|
||||
using convert_to(client_response_id, 'UTF8'),
|
||||
alter column upstream_response_id type bytea
|
||||
using convert_to(upstream_response_id, 'UTF8');
|
||||
@@ -1,9 +0,0 @@
|
||||
alter table model_requests
|
||||
add column service_tier text,
|
||||
add constraint model_requests_service_tier_ck check (
|
||||
service_tier is null
|
||||
or (
|
||||
octet_length(service_tier) between 1 and 64
|
||||
and service_tier !~ '[[:cntrl:]]'
|
||||
)
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
alter table provider_accounts
|
||||
drop constraint provider_accounts_cooldown_ck,
|
||||
add constraint provider_accounts_cooldown_ck check (
|
||||
(
|
||||
availability != 'cooldown'
|
||||
or cooldown_until is not null
|
||||
)
|
||||
and (
|
||||
cooldown_until is null
|
||||
or availability in ('cooldown', 'quota_exhausted')
|
||||
)
|
||||
);
|
||||
@@ -6,14 +6,16 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
|
||||
|
||||
## 冻结规则
|
||||
|
||||
- 已合入 main 的迁移文件**永久冻结**,一个字节都不能改——包括注释和空白。
|
||||
任何改动都会让所有已部署实例(含自更新链路上的存量用户)在下次重启时
|
||||
因 checksum 失配而无法启动,且没有自动恢复路径。
|
||||
- schema 变更一律新增编号迁移(`0003_...`、`0004_...`),永远不回改旧文件。
|
||||
- 同一大版本内,已合入 main 的迁移文件**永久冻结**,一个字节都不能改——包括注释和
|
||||
空白。任何改动都会让该大版本的已部署实例在下次重启时因 checksum 失配而无法启动,
|
||||
且没有自动恢复路径。
|
||||
- 同一大版本内的 schema 变更一律新增编号迁移(`0002_...`、`0003_...`),永远不回改
|
||||
旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本
|
||||
不支持在线升级,目标版本必须使用全新数据目录部署。
|
||||
- `.frozen-sha256` 是冻结清单(`sha256sum` 格式)。CI 会校验:清单内文件
|
||||
字节未变,且目录里每个 `*.sql` 都已入册。新增迁移时在同一提交里执行
|
||||
`sha256sum 000N_xxx.sql >> .frozen-sha256` 入册。
|
||||
- 若确需修正已冻结迁移里的错误,用新迁移做补偿性变更(`alter` / 回填),
|
||||
- 若同一大版本内确需修正已冻结迁移里的错误,用新迁移做补偿性变更(`alter` / 回填),
|
||||
不要动原文件。
|
||||
|
||||
## 本地测试库
|
||||
|
||||
+2
-4
@@ -130,10 +130,8 @@ Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建
|
||||
## 镜像升级与源码构建
|
||||
|
||||
> [!WARNING]
|
||||
> 以下命令只适用于同一大版本内的升级。数据库 schema 与配置只在同一大版本内兼容,
|
||||
> 跨大版本直接换镜像会导致服务无法启动;内置一键自更新会拒绝跨大版本目标(3.0 之前
|
||||
> 的旧版没有这道门禁,不要用旧版界面的一键更新跨大版本)。跨大版本请全新部署(全新
|
||||
> `.runtime/` 数据目录),并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
> 以下命令只适用于同一大版本内的升级,不支持跨大版本在线升级。跨大版本请使用全新的
|
||||
> `.runtime/` 数据目录重新部署,并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml build codex-proxy-rs
|
||||
|
||||
@@ -170,11 +170,11 @@ Admin API 不要求客户端提交配置 revision,也不向客户端暴露配
|
||||
客户端可见协议结果。队列不是第二份业务权威,也不做无幂等键的盲目重试。
|
||||
- retention 只删除已满足保留规则的历史事实,不改变运行中请求。
|
||||
|
||||
业务 schema 由 `backend/migrations/` 下的编号迁移按序定义:`0001_initial.sql` 建表,`0002` 删除
|
||||
opaque response ID 唯一索引,`0003` 将 client/upstream response ID 改为无格式假设的 UTF-8 bytes,
|
||||
`0004` 增加响应观测得到的 `model_requests.service_tier`。已合入的迁移永久冻结——
|
||||
`.frozen-sha256` 是冻结清单,CI 校验清单相对 PR base 只增不改,启动时 sqlx 重校验已应用迁移的
|
||||
checksum,不一致直接拒绝启动。规则见 `backend/migrations/README.md`。
|
||||
业务 schema 由 `backend/migrations/0001_initial.sql` 直接定义当前大版本的完整新库基线,其中
|
||||
response ID 使用无格式假设的 UTF-8 bytes,`model_requests.service_tier` 保存响应观测到的服务档位,
|
||||
且额度耗尽账号可以携带冷却截止时间。同一大版本内已合入的迁移永久冻结——
|
||||
`.frozen-sha256` 是冻结清单,CI 校验清单相对 PR base 只增不改,启动时 sqlx 重校验已应用
|
||||
迁移的 checksum,不一致直接拒绝启动。规则见 `backend/migrations/README.md`。
|
||||
|
||||
## 9. Redis 终态
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const overviewItems = computed(() => [
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
label: '不可用账号',
|
||||
label: '异常账号',
|
||||
value: formatCount(props.summary.unavailable),
|
||||
caption: '过期 / 禁用 / 封禁',
|
||||
tone: 'danger',
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.28"
|
||||
version: "3.0.32"
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
## 存储
|
||||
|
||||
- PostgreSQL 业务表只有 `0001_initial.sql` 定义的七张;后续编号迁移只做增量。当前 `0002` 删除
|
||||
opaque response ID 索引,`0003` 把 response ID 改为 bytes,`0004` 增加响应观测的 service tier。
|
||||
- 已应用迁移按字节冻结:`backend/migrations/.frozen-sha256` 是冻结清单,CI 相对 PR base 做 append-only 校验;schema 变更一律新增编号迁移,规则见 `backend/migrations/README.md`。
|
||||
- PostgreSQL 业务表只有 `0001_initial.sql` 定义的七张;该文件直接表达当前大版本的完整新库基线,
|
||||
包括 bytes response ID、响应观测 service tier 与额度耗尽冷却约束。
|
||||
- 同一大版本内已应用迁移按字节冻结:`backend/migrations/.frozen-sha256` 是冻结清单,CI 相对 PR base 做 append-only 校验;schema 变更一律新增编号迁移,规则见 `backend/migrations/README.md`。
|
||||
- `config_revision` 只用于会改变调度快照或安全配置的管理 mutation。
|
||||
- quota、cooldown、catalog generation、自动 refresh 不推进全局 revision。
|
||||
- refresh 只推进账号 `credential_revision`;Redis cooldown 是可丢失热缓存。
|
||||
|
||||
Reference in New Issue
Block a user