Compare commits

...
6 Commits
Author SHA1 Message Date
ZyyandCodex b206ee8271 chore: bump version to 3.3.30
Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:28:01 +08:00
ZyyandCodex 70ff923033 fix(frontend): prevent pagination overflow on narrow screens
Adapt visible page buttons to the pagination container width while preserving the existing button styles. Hide the total count on narrow containers and keep the page-size selector on the left with navigation on the right. Retain the total count and full pager on wider layouts.

Validation: browser layout checks across 320–1280px widths and up to 1380 pages, pagination interactions and disabled states, frontend typecheck, targeted ESLint, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:26:55 +08:00
ZyyandCodex a2fbc073a4 refactor(store): consolidate database migrations into initial baseline
Fold the final columns, constraints, and indexes into the initial schema and remove superseded migrations and historical backfills. Refresh the frozen checksum manifest, simplify migration test setup, and retain canonical audit identity coverage. Keep migration documentation focused on general rules.

Validation: PostgreSQL schema dumps match before and after consolidation, all 125 PostgreSQL tests passed, and frozen checksum, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:11:49 +08:00
ZyyandCodex 9944041dc8 fix(openai): allow more time for WebSocket heartbeat responses
Increase the default heartbeat timeout from 5 to 30 seconds so brief upstream stalls have more time to recover during response generation. Document that heartbeat checks also apply to active pooled connections and accept any inbound frame as liveness.

Validation: WebSocket pool tests (26 passed), strict OpenAI Clippy with all targets and features, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 17:46:45 +08:00
ZyyandCodex 18a04f1213 fix(openai): restore periodic quota checks and timely reset recovery
Recheck exhausted accounts every 30 minutes even before their recorded reset, so early provider resets can be discovered. Allow each pending reset boundary to trigger an earlier check after a two-minute grace period, then return to periodic retries if exhaustion persists.

Separate quota checks from model catalog refreshes. Scan quota eligibility every 30 seconds while retaining the configured catalog interval and independent per-window recovery rules. Keep quota timing regressions in the mirrored integration-test directory and exercise the public service.

Validation: quota integration tests (46 passed), gateway application and architecture tests (36 passed), strict OpenAI Clippy with all targets and features, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 15:39:41 +08:00
ZyyandCodex 35988d0eb7 fix(billing): correct provider pricing and service tier accounting
Use the actual response tier for OpenAI costs and persisted observations.
Replace broad model-prefix pricing with explicit variants and documented
aliases and snapshots. Retain regular Sol prices, excluding temporary
promotions, and add GPT Image 2.5 Sunburst and Flare token pricing.

Include File Search call fees and leave incomplete tool totals unpriced.
Add Grok 4.6 and confirmed Priority rates, persist the served xAI tier,
and preserve authoritative zero-cost responses. Reject inconsistent cache
counts, invalid totals and token-count overflow during local estimation.

Validation: OpenAI, xAI and gateway-protocol tests, additional billing and
compaction regressions, workspace rustfmt, strict workspace Clippy with
all targets and features, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 13:37:12 +08:00
43 changed files with 1402 additions and 526 deletions
@@ -65,7 +65,8 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
let image_output_tokens =
nested_number_field(body, &["tool_usage", "image_gen", "output_tokens"])
.unwrap_or_default();
let total_tokens = number_field(usage, "total_tokens").unwrap_or(input_tokens + output_tokens);
let total_tokens = number_field(usage, "total_tokens")
.unwrap_or_else(|| input_tokens.saturating_add(output_tokens));
let has_usage = [
"input_tokens",
@@ -98,7 +99,7 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
})
}
/// 校验计价所需的输入、输出和缓存读取字段,拒绝缺失、非法或不一致的数值。
/// 校验计价所需的 token 字段,拒绝缺失、非法、溢出或不一致的数值。
///
/// 用量提取允许缺失字段补零;计价必须额外确认这些零值有明确的上游事实。
#[must_use]
@@ -122,11 +123,28 @@ pub fn billable_usage_is_complete(response: &Value, usage: TokenUsage) -> bool {
Some(value) => value.as_u64(),
None => Some(0),
};
let written = raw
.pointer("/input_tokens_details/cache_write_tokens")
.or_else(|| raw.pointer("/prompt_tokens_details/cache_write_tokens"))
.or_else(|| raw.get("cache_write_tokens"));
let written = match written {
Some(value) => value.as_u64(),
None => Some(0),
};
let total = usage.input_tokens.checked_add(usage.output_tokens);
input == Some(usage.input_tokens)
&& output == Some(usage.output_tokens)
&& cached == Some(usage.cached_tokens)
&& usage.cached_tokens <= usage.input_tokens
&& written == Some(usage.cache_write_tokens)
&& usage
.cached_tokens
.checked_add(usage.cache_write_tokens)
.is_some_and(|cached| cached <= usage.input_tokens)
&& total == Some(usage.total_tokens)
&& raw
.get("total_tokens")
.is_none_or(|value| value.as_u64() == total)
}
/// 从完整 SSE 文本中提取最终可见用量。
@@ -5,6 +5,57 @@ use gateway_protocol::openai::events::{
};
use serde_json::json;
#[test]
fn billable_usage_should_validate_cache_writes_totals_and_overflow() {
for (raw, expected) in [
(
json!({"input_tokens": 100, "output_tokens": 10, "input_tokens_details": {"cached_tokens": 20, "cache_write_tokens": 80}, "total_tokens": 110}),
true,
),
(
json!({"prompt_tokens": 100, "completion_tokens": 10, "prompt_tokens_details": {"cached_tokens": 20, "cache_write_tokens": 80}}),
true,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cached_tokens": 20, "cache_write_tokens": 90}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cache_write_tokens": -1}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "input_tokens_details": {"cache_write_tokens": "1"}, "cache_write_tokens": 1}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cache_write_tokens": 1.5}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "total_tokens": 111}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "total_tokens": "110"}),
false,
),
(json!({"input_tokens": u64::MAX, "output_tokens": 1}), false),
(
json!({"input_tokens": u64::MAX, "output_tokens": 0, "cached_tokens": u64::MAX, "cache_write_tokens": 1}),
false,
),
] {
let response = json!({"usage": raw});
let usage = extract_usage(&response).expect("提取用量不得因溢出而崩溃");
assert_eq!(
billable_usage_is_complete(&response, usage),
expected,
"{response}"
);
}
}
#[test]
fn billable_usage_should_require_valid_input_output_and_cache_facts() {
for (raw, expected) in [
@@ -55,10 +55,6 @@ pub(super) fn admin_account_store(pool: &PgPool) -> PgAdminAccountStore {
impl TestDatabase {
pub(super) async fn create(label: &str) -> Option<Self> {
Self::create_at(label, i64::MAX).await
}
pub(super) async fn create_at(label: &str, version: i64) -> Option<Self> {
let database_url = crate::support::test_env("CPR_TEST_DATABASE_URL")?;
let schema = format!("cpr_store_{label}_{}", Uuid::new_v4().simple());
let admin = PgPoolOptions::new()
@@ -86,16 +82,10 @@ impl TestDatabase {
.connect(&database_url)
.await
.expect("connect isolated test schema");
sqlx::migrate::Migrator::with_migrations(
TEST_MIGRATOR
.iter()
.filter(|migration| migration.version <= version)
.cloned()
.collect(),
)
.run(&pool)
.await
.expect("apply test migrations");
TEST_MIGRATOR
.run(&pool)
.await
.expect("apply test migrations");
Some(Self {
admin,
pool,
@@ -1,4 +1,4 @@
use super::{TEST_MIGRATOR, TestDatabase};
use super::TestDatabase;
async fn seed_request(pool: &sqlx::PgPool) {
sqlx::query(
@@ -101,8 +101,8 @@ async fn account_snapshots_are_required_before_live_foreign_keys_can_be_cleared(
}
#[tokio::test]
async fn upgrade_normalizes_admin_identity_and_preserves_existing_event_rows() {
let Some(db) = TestDatabase::create_at("audit_upgrade", 7).await else {
async fn audit_requires_canonical_admin_identity_and_retains_it_after_admin_deletion() {
let Some(db) = TestDatabase::create("audit_identity").await else {
return;
};
sqlx::raw_sql(
@@ -110,28 +110,12 @@ async fn upgrade_normalizes_admin_identity_and_preserves_existing_event_rows() {
values ('admin_test', 'test_hash', now(), now());
insert into admin_audit_events (id, actor_kind, actor_admin_user_id, actor_ref,
action, entity_kind, entity_ref, created_at)
values ('audit_before_upgrade', 'admin_session', 'admin_test', 'admin_test',
'update', 'settings', '1', now());
insert into ops_events (id, level, component, operation, failure_kind, message, created_at)
values ('ops_before_upgrade', 'error', 'probe', 'probe', 'timeout', 'test', now());",
values ('audit_identity', 'admin_session', 'admin_test', 'admin:admin_test',
'update', 'settings', '1', now());",
)
.execute(&db.pool)
.await
.expect("seed data from pre-optimization schema");
TEST_MIGRATOR
.run(&db.pool)
.await
.expect("upgrade populated database");
let identity: String = sqlx::query_scalar("select actor_ref from admin_audit_events")
.fetch_one(&db.pool)
.await
.expect("read normalized identity");
assert_eq!(identity, "admin:admin_test");
let events: i64 = sqlx::query_scalar("select count(*) from ops_events")
.fetch_one(&db.pool)
.await
.expect("read retained events");
assert_eq!(events, 1);
.expect("seed audit event with canonical admin identity");
let error = sqlx::query("update admin_audit_events set actor_ref = 'admin_test'")
.execute(&db.pool)
.await
@@ -213,6 +213,7 @@ impl CodexWebSocketPoolSettings {
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CodexQuotaSettings {
/// 保留模型目录的刷新周期;额度独立每 30 秒检查周期复核和 reset 到期条件。
pub refresh_interval_minutes: u64,
}
@@ -57,8 +57,8 @@ use snapshot::{
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
const EXHAUSTED_QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限。
const INITIAL_QUOTA_SYNC_BATCH: usize = 100;
// 5xx 上游拒绝的短退避重试预算;指数退避 1s/2s,吞掉瞬时抖动。
@@ -68,8 +68,8 @@ const QUOTA_FETCH_5XX_BASE_DELAY: Duration = Duration::from_secs(1);
/// OpenAI Provider 主动额度刷新的调度策略。
///
/// 正常账号依赖请求响应的被动额度同步;周期 worker 仅复核已耗尽账号。
/// 该策略只控制 Provider 自己的 quota worker 频率,避免把 OpenAI
/// 额度语义泄漏到公共调度层。
/// 该策略保留模型目录的周期刷新频率;额度到期检查使用独立的短周期,
/// 避免到达 reset 后还要等待完整的目录刷新周期。
#[derive(Debug, Clone, Copy)]
pub struct CodexQuotaRefreshPolicy {
interval: Duration,
@@ -204,7 +204,12 @@ struct CodexQuotaSchedulingProjection {
struct CodexQuotaProjectionState {
next_version: u64,
entries: BTreeMap<ProviderAccountId, CodexQuotaSchedulingEntry>,
last_periodic_refresh_at: BTreeMap<ProviderAccountId, Instant>,
last_periodic_refresh_at: BTreeMap<ProviderAccountId, CodexQuotaRefreshAttempt>,
}
struct CodexQuotaRefreshAttempt {
monotonic_at: Instant,
wall_at: SystemTime,
}
#[derive(Debug, Clone, Copy)]
@@ -398,16 +403,27 @@ impl CodexQuotaSchedulingProjection {
.last_periodic_refresh_at
.retain(|account_id, _| candidate_ids.contains(account_id));
// 正常账号只由真实请求的响应头和 `codex.rate_limits` 被动同步。定时器只复核
// 已耗尽且已到 reset/fallback 时刻的账号;重复复核至少间隔 30 分钟。
// 正常账号只由真实请求的响应头和 `codex.rate_limits` 被动同步。
// 已耗尽账号每 30 分钟复核,不能等待旧 reset:官方活动可能提前重置额度。
// reset + 2 分钟可提前触发一次复核,给上游重置留出传播时间。
let mut reserved = Vec::new();
for account in candidates {
if !periodic_quota_refresh_due(&state, account.id(), refreshed_at) {
if !periodic_quota_refresh_due(
&state,
account.id(),
account.quota().reset_at(),
now,
refreshed_at,
) {
continue;
}
state
.last_periodic_refresh_at
.insert(account.id().clone(), refreshed_at);
state.last_periodic_refresh_at.insert(
account.id().clone(),
CodexQuotaRefreshAttempt {
monotonic_at: refreshed_at,
wall_at: now,
},
);
reserved.push(account);
}
reserved
@@ -415,24 +431,27 @@ impl CodexQuotaSchedulingProjection {
}
fn quota_refresh_candidate(account: ProviderAccount, now: SystemTime) -> Option<ProviderAccount> {
// reset 到期是“应刷新”的信号,不是“已恢复”的证据。
(eligible_periodic_quota_refresh(&account, now)
&& account
.quota()
.exhaustion_refresh_due(now, EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL))
.then_some(account)
(eligible_periodic_quota_refresh(&account, now) && account.quota().is_exhausted())
.then_some(account)
}
fn periodic_quota_refresh_due(
state: &CodexQuotaProjectionState,
account_id: &ProviderAccountId,
now: Instant,
reset_at: Option<SystemTime>,
now: SystemTime,
monotonic_now: Instant,
) -> bool {
state
.last_periodic_refresh_at
.get(account_id)
.is_none_or(|last| {
now.saturating_duration_since(*last) >= EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL
monotonic_now.saturating_duration_since(last.monotonic_at)
>= EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL
|| reset_at
.and_then(|reset| reset.checked_add(EXHAUSTED_QUOTA_RESET_GRACE))
// 已在该边界之后复核过时回到周期重试,避免过期 reset 每轮触发。
.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
})
}
@@ -145,9 +145,9 @@ impl OpenAiResponseObservationState {
}
pub(super) fn effective_service_tier(&self) -> Option<&str> {
self.requested_service_tier
self.upstream_service_tier
.as_deref()
.or(self.upstream_service_tier.as_deref())
.or(self.requested_service_tier.as_deref())
}
pub(super) fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
@@ -7,8 +7,10 @@ pub(super) const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
pub(super) const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
pub(super) const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
pub(super) const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
pub(super) const QUOTA_CHECK_INTERVAL: Duration = Duration::from_secs(30);
pub(super) const DESKTOP_RELEASE_WORKER_OWNER: &str = "openai-desktop-release";
pub(super) const MODEL_ETAG_WORKER_OWNER: &str = "openai-model-etag";
pub(super) const MODEL_CATALOG_WORKER_OWNER: &str = "openai-model-catalog";
pub(crate) fn worker_contributions(
refresh: Arc<CodexCredentialRefreshService>,
@@ -20,6 +22,7 @@ pub(crate) fn worker_contributions(
) -> Result<Vec<WorkerContribution>, WorkerDefinitionError> {
let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, PROVIDER_NAME)?;
let quota_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, PROVIDER_NAME)?;
let catalog_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_CATALOG_WORKER_OWNER)?;
let etag_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_ETAG_WORKER_OWNER)?;
let desktop_release_id =
WorkerId::try_new(WorkerKind::QuotaCatalogHealth, DESKTOP_RELEASE_WORKER_OWNER)?;
@@ -34,9 +37,13 @@ pub(crate) fn worker_contributions(
contributions.extend([
WorkerContribution::Registration(scheduled_registration(
quota_id,
QUOTA_CHECK_INTERVAL,
Box::new(OpenAiQuotaTask { quota }),
)?),
WorkerContribution::Registration(scheduled_registration(
catalog_id,
quota_refresh_policy.interval(),
Box::new(OpenAiQuotaTask {
quota,
Box::new(OpenAiCatalogTask {
catalog: Arc::clone(&catalog),
}),
)?),
@@ -159,6 +166,9 @@ impl ScheduledTask for OpenAiOAuthRefreshTask {
pub(super) struct OpenAiQuotaTask {
quota: Arc<CodexCredentialQuotaService>,
}
pub(super) struct OpenAiCatalogTask {
catalog: Arc<CodexCredentialCatalogService>,
}
@@ -195,7 +205,6 @@ impl ScheduledTask for OpenAiQuotaTask {
if context.cancellation().is_cancelled() {
return Ok(());
}
let mut failures = false;
match self.quota.synchronize().await {
Ok(summary) if summary.has_operational_failures() => {
tracing::warn!(
@@ -209,30 +218,33 @@ impl ScheduledTask for OpenAiQuotaTask {
}
Ok(_) => {}
Err(error) => {
failures = true;
tracing::warn!(
error = %error,
"OpenAI quota synchronization failed"
);
return Err(WorkerTaskError::safe("OpenAI quota synchronization failed"));
}
}
Ok(())
})
}
}
impl ScheduledTask for OpenAiCatalogTask {
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
if context.cancellation().is_cancelled() {
return Ok(());
}
match self.catalog.refresh_catalogs().await {
Ok(_) | Err(CodexCredentialCatalogError::NoEligibleCredential) => {}
Ok(_) | Err(CodexCredentialCatalogError::NoEligibleCredential) => Ok(()),
Err(error) => {
failures = true;
tracing::warn!(error = %error, "OpenAI model catalog refresh failed");
Err(WorkerTaskError::safe(
"OpenAI model catalog synchronization failed",
))
}
}
if failures {
Err(WorkerTaskError::safe(
"OpenAI quota or catalog synchronization failed",
))
} else {
Ok(())
}
})
}
}
@@ -158,7 +158,7 @@ impl CodexCanonicalDecoder {
self
}
/// 请求显式指定的档位优先用于计费;响应档位只在请求未指定时兜底。
/// 响应未报告实际档位时,才使用请求档位估算费用。
#[must_use]
pub fn with_requested_service_tier(mut self, service_tier: Option<&str>) -> Self {
self.requested_service_tier = normalize_service_tier(service_tier);
@@ -862,18 +862,19 @@ impl CodexCanonicalDecoder {
.unwrap_or(&self.fallback_model)
.to_owned();
let service_tier = self
.requested_service_tier
.response_service_tier
.as_deref()
.or(self.response_service_tier.as_deref());
let web_search_calls = web_search_call_count(response);
.or(self.requested_service_tier.as_deref());
let tool_calls = billable_tool_calls(response);
if let Some(breakdown) = usage
.filter(|usage| billable_usage_is_complete(response, *usage))
.and_then(|usage| {
let web_search_calls = web_search_calls?;
let (web_search_calls, file_search_calls) = tool_calls?;
openai_billing_breakdown(
&model,
OpenAiBillingUsage::from(usage)
.with_web_search_calls(web_search_calls, self.web_search_pricing),
.with_web_search_calls(web_search_calls, self.web_search_pricing)
.with_file_search_calls(file_search_calls),
service_tier,
)
})
@@ -1000,26 +1001,43 @@ fn core_usage(usage: TokenUsage) -> Usage {
normalized
}
fn web_search_call_count(response: &Value) -> Option<u64> {
let mut calls = 0_u64;
for item in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call"))
{
match item
.get("action")
.and_then(|action| action.get("type"))
.and_then(Value::as_str)
{
Some("search") => calls = calls.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
Some(_) | None => return None,
fn billable_tool_calls(response: &Value) -> Option<(u64, u64)> {
let mut web = 0_u64;
let mut file = 0_u64;
let items = match response.get("output") {
Some(value) => value.as_array()?.as_slice(),
None => &[],
};
for item in items {
match item.get("type").and_then(Value::as_str)? {
"web_search_call" => match item.pointer("/action/type").and_then(Value::as_str) {
Some("search") => web = web.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
_ => return None,
},
"file_search_call"
if item.get("status").and_then(Value::as_str) == Some("completed") =>
{
file = file.checked_add(1)?;
}
// 这些输出没有独立的 OpenAI 工具调用费。
"message"
| "reasoning"
| "function_call"
| "custom_tool_call"
| "computer_call"
| "local_shell_call"
| "apply_patch_call"
| "mcp_call"
| "mcp_list_tools"
| "mcp_approval_request"
| "compaction" => {}
// 容器会话、生图及未知工具需要额外计费事实,不能把 token 小计
// 当作完整费用。
_ => return None,
}
}
Some(calls)
Some((web, file))
}
fn incomplete_finish_reason(response: &Value) -> FinishReason {
@@ -16,6 +16,7 @@ use super::{
const LONG_CONTEXT_THRESHOLD: u64 = 272_000;
const WEB_SEARCH_CALL_TICKS: u128 = 100_000_000;
const WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS: u128 = 250_000_000;
const FILE_SEARCH_CALL_TICKS: u128 = 25_000_000;
/// OpenAI 公开 Token 价格计算所需的单次用量事实。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -27,6 +28,7 @@ pub struct OpenAiBillingUsage {
image_input_tokens: u64,
image_output_tokens: u64,
web_search_calls: u64,
file_search_calls: u64,
web_search_pricing: Option<WebSearchPricing>,
}
@@ -47,6 +49,7 @@ impl OpenAiBillingUsage {
image_input_tokens: 0,
image_output_tokens: 0,
web_search_calls: 0,
file_search_calls: 0,
web_search_pricing: None,
}
}
@@ -60,6 +63,11 @@ impl OpenAiBillingUsage {
self.web_search_pricing = pricing;
self
}
pub(crate) const fn with_file_search_calls(mut self, calls: u64) -> Self {
self.file_search_calls = calls;
self
}
}
impl From<TokenUsage> for OpenAiBillingUsage {
@@ -72,6 +80,7 @@ impl From<TokenUsage> for OpenAiBillingUsage {
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
web_search_calls: 0,
file_search_calls: 0,
web_search_pricing: None,
}
}
@@ -125,6 +134,7 @@ struct ModelPricing {
long_flex: TokenRates,
long_fast: TokenRates,
cache_write_percent: u32,
unpriced_long_context: bool,
}
impl ModelPricing {
@@ -137,6 +147,7 @@ impl ModelPricing {
long_flex: TokenRates::ZERO,
long_fast: TokenRates::ZERO,
cache_write_percent: 0,
unpriced_long_context: false,
}
}
@@ -170,7 +181,15 @@ impl ModelPricing {
self
}
const fn with_unpriced_long_context(mut self) -> Self {
self.unpriced_long_context = true;
self
}
fn rates(self, tier: PricingTier, long_context: bool) -> Option<TokenRates> {
if long_context && self.unpriced_long_context {
return None;
}
// Only models with a published long-context column switch at the threshold.
// A dash in that column is not a second, unavailable price tier for models
// whose entire supported context is covered by the short-context price.
@@ -200,10 +219,12 @@ struct PricingRule {
pricing: ModelPricing,
}
// Source: https://developers.openai.com/api/docs/pricing (verified 2026-08-14).
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文
// 档位也统一按常规价计算。
const PRICING_RULES: &[PricingRule] = &[
// Astra: https://developers.openai.com/api/docs/models/gpt-6-astra
// Rates verified against the pricing table on 2026-09-05.
// Astra:https://developers.openai.com/api/docs/models/gpt-6-astra
// 已于 2026-09-09 对照官方价目表核验。
PricingRule {
model: "gpt-6-astra",
pricing: ModelPricing::new(100_000, 500_000, 10_000)
@@ -405,10 +426,60 @@ const PRICING_RULES: &[PricingRule] = &[
model: "gpt-3.5-turbo",
pricing: ModelPricing::new(5_000, 15_000, 0),
},
// 各变体独立采用已公开的价格与档位,不能继承父型号的所有档位。
// Cyber 长上下文的官方来源存在差异,该区间暂不估价。
PricingRule {
model: "gpt-5.1-codex-mini",
pricing: ModelPricing::new(2_500, 20_000, 250),
},
PricingRule {
model: "gpt-5.3-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.6-cyber",
pricing: ModelPricing::new(125_000, 750_000, 12_500)
.with_cache_write(125)
.with_unpriced_long_context(),
},
PricingRule {
model: "gpt-5.5-cyber",
pricing: ModelPricing::new(125_000, 750_000, 12_500).with_unpriced_long_context(),
},
PricingRule {
model: "chat-latest",
pricing: ModelPricing::new(50_000, 300_000, 5_000),
},
PricingRule {
model: "gpt-5-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex-max",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.2-codex",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.2-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
];
const UNPRICED_MODELS: &[&str] = &["gpt-5.3-codex-spark"];
#[derive(Clone, Copy)]
struct TokenAmounts {
input_ticks: u128,
@@ -439,13 +510,14 @@ fn openai_billing_breakdown_with_context(
service_tier: Option<&str>,
long_context: bool,
) -> Option<CalculatedCostBreakdown> {
if usage.cached_tokens > usage.input_tokens
if usage.cached_tokens.checked_add(usage.cache_write_tokens)? > usage.input_tokens
|| usage.image_input_tokens > 0
|| usage.image_output_tokens > 0
{
return None;
}
let web_search_ticks = web_search_amount_ticks(usage)?;
let tool_ticks = web_search_amount_ticks(usage)?
.checked_add(u128::from(usage.file_search_calls).checked_mul(FILE_SEARCH_CALL_TICKS)?)?;
let pricing = model_pricing(model)?;
let normalized_tier = normalize_service_tier(service_tier);
let tier = pricing_tier(normalized_tier.as_deref())?;
@@ -467,8 +539,8 @@ fn openai_billing_breakdown_with_context(
usage.cached_tokens,
usage.cache_write_tokens,
)?;
standard.total_ticks = standard.total_ticks.checked_add(web_search_ticks)?;
selected.total_ticks = selected.total_ticks.checked_add(web_search_ticks)?;
standard.total_ticks = standard.total_ticks.checked_add(tool_ticks)?;
selected.total_ticks = selected.total_ticks.checked_add(tool_ticks)?;
let multiplier_percent =
effective_multiplier_percent(selected.total_ticks, standard.total_ticks)?;
let cache_write_rate = cache_write_rate(selected_rates, pricing.cache_write_percent)?;
@@ -503,7 +575,12 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
let request = serde_json::from_slice::<ImageModel>(request_body).ok()?;
if !matches!(
request.model.as_str(),
"gpt-image-2" | "gpt-image-2-2026-04-21"
"gpt-image-2"
| "gpt-image-2-2026-04-21"
| "gpt-image-2.5-sunburst"
| "gpt-image-2.5-sunburst-2026-09-08"
| "gpt-image-2.5-flare"
| "gpt-image-2.5-flare-2026-09-08"
) {
return None;
}
@@ -542,8 +619,9 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
(_, _, cached) if cached == input => (text_input, image_input),
_ => return None,
};
// https://developers.openai.com/api/docs/pricing (2026-09-08)
// USD / 1M tokens: text 5 / cached 1.25; image 8 / cached 2 / output 30.
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// GPT Image 2 和两个 2.5 型号按相同的 token 单价计费,不使用按张估价。
// 每百万 token 的美元单价:文本 5 / 缓存 1.25;图片 8 / 缓存 2 / 输出 30。
let text = token_amounts(
TokenRates::new(50_000, 0, 12_500),
0,
@@ -565,16 +643,10 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
fn model_pricing(model: &str) -> Option<ModelPricing> {
let normalized = normalize_model_name(model);
if UNPRICED_MODELS
.iter()
.any(|rule| model_matches_rule(&normalized, rule))
{
return None;
}
let model = pricing_model_name(&normalized);
PRICING_RULES
.iter()
.filter(|rule| model_matches_rule(&normalized, rule.model))
.max_by_key(|rule| rule.model.len())
.find(|rule| model == rule.model)
.map(|rule| rule.pricing)
}
@@ -613,16 +685,17 @@ fn fixed_block_web_search_model(model: &str) -> bool {
fn reasoning_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
normalized.starts_with("gpt-5")
|| model_matches_rule(&normalized, "gpt-6-astra")
|| normalized.starts_with("o1")
|| normalized.starts_with("o3")
|| normalized.starts_with("o4")
let model = pricing_model_name(&normalized);
model.starts_with("gpt-5")
|| model == "gpt-6-astra"
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
}
fn pricing_tier(service_tier: Option<&str>) -> Option<PricingTier> {
match service_tier {
None | Some("auto" | "default" | "standard") => Some(PricingTier::Standard),
None | Some("default" | "standard") => Some(PricingTier::Standard),
Some("flex") => Some(PricingTier::Flex),
Some("fast" | "priority") => Some(PricingTier::Fast),
Some(_) => None,
@@ -644,20 +717,23 @@ fn token_amounts(
cached_tokens: u64,
cache_write_tokens: u64,
) -> Option<TokenAmounts> {
if cached_tokens.checked_add(cache_write_tokens)? > input_tokens {
return None;
}
let billed_cache_read = if rates.cache_read_ticks > 0 {
cached_tokens.min(input_tokens)
cached_tokens
} else {
0
};
let cache_write_rate = cache_write_rate(rates, cache_write_percent)?;
let billed_cache_write = if cache_write_rate > 0 {
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
cache_write_tokens
} else {
0
};
let uncached_input = input_tokens
.saturating_sub(billed_cache_read)
.saturating_sub(billed_cache_write);
.checked_sub(billed_cache_read)?
.checked_sub(billed_cache_write)?;
let input_ticks = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
let output_ticks = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
let cache_read_ticks = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
@@ -703,13 +779,47 @@ fn normalize_model_name(model: &str) -> String {
.to_ascii_lowercase()
}
fn model_matches_rule(model: &str, rule: &str) -> bool {
if model == rule {
return true;
// 仅已核验的别名和快照可以共用价格;未知后缀、未来日期和微调模型
// 不得直接继承父型号的价格。
fn pricing_model_name(model: &str) -> &str {
match model {
"gpt-3.5-turbo-0125" => "gpt-3.5-turbo",
"gpt-4-0314" => "gpt-4",
"gpt-4-0613" => "gpt-4",
"gpt-4-turbo-2024-04-09" => "gpt-4-turbo",
"gpt-4.1-2025-04-14" => "gpt-4.1",
"gpt-4.1-mini-2025-04-14" => "gpt-4.1-mini",
"gpt-4.1-nano-2025-04-14" => "gpt-4.1-nano",
"gpt-4o-2024-08-06" => "gpt-4o",
"gpt-4o-2024-11-20" => "gpt-4o",
"gpt-4o-mini-2024-07-18" => "gpt-4o-mini",
"gpt-5-2025-08-07" => "gpt-5",
"gpt-5-mini-2025-08-07" => "gpt-5-mini",
"gpt-5-nano-2025-08-07" => "gpt-5-nano",
"gpt-5-pro-2025-10-06" => "gpt-5-pro",
"gpt-5.1-2025-11-13" => "gpt-5.1",
"gpt-5.2-2025-12-11" => "gpt-5.2",
"gpt-5.2-pro-2025-12-11" => "gpt-5.2-pro",
"gpt-5.4-2026-03-05" => "gpt-5.4",
"gpt-5.4-mini-2026-03-17" => "gpt-5.4-mini",
"gpt-5.4-nano-2026-03-17" => "gpt-5.4-nano",
"gpt-5.4-pro-2026-03-05" => "gpt-5.4-pro",
"gpt-5.5-2026-04-23" => "gpt-5.5",
"gpt-5.5-pro-2026-04-23" => "gpt-5.5-pro",
"gpt-daybreak-blue-latest" => "gpt-5.6-sol",
"gpt-daybreak-red-latest" => "gpt-5.6-cyber",
"o1-2024-12-17" => "o1",
"o1-pro-2025-03-19" => "o1-pro",
"o3-2025-04-16" => "o3",
"o3-mini-2025-01-31" => "o3-mini",
"o3-pro-2025-06-10" => "o3-pro",
"o4-mini-2025-04-16" => "o4-mini",
_ => model,
}
model
.strip_prefix(rule)
.is_some_and(|suffix| matches!(suffix.as_bytes().first(), Some(b'-' | b'.' | b':')))
}
fn model_matches_rule(model: &str, rule: &str) -> bool {
pricing_model_name(model) == rule
}
/// 规范化请求或响应携带的服务档位,供观测与计费共用。
@@ -36,7 +36,8 @@ const DEFAULT_MAX_CONNECTING: usize = 16;
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(25);
const DEFAULT_PING_TIMEOUT: Duration = Duration::from_secs(5);
// 心跳也覆盖正在生成的连接,给短时链路停顿留出恢复余量。
const DEFAULT_PING_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
/// WebSocket 连接池。
@@ -67,9 +68,9 @@ pub struct CodexWebSocketPoolConfig {
pub max_connecting: usize,
/// 后台维护间隔;`None` 表示不启动后台任务。
pub maintenance_interval: Option<Duration>,
/// idle socket 探活 ping 间隔;`None` 表示维护时只做过期清理。
/// 池化连接的探活 ping 间隔(包括正在生成的连接);`None` 表示不主动 ping。
pub ping_interval: Option<Duration>,
/// 发送 ping 后等待上游响应的超时时间;零值表示不校验 Pong deadline。
/// 发送 ping 后等待任意入站帧的超时时间;零值表示不校验 Pong deadline。
pub ping_timeout: Duration,
/// idle socket 无活动多久后视为失活。
pub liveness_timeout: Option<Duration>,
@@ -2,7 +2,7 @@
//!
//! 每条上游 WebSocket 连接都由一个后台 pump 任务独占:
//! - 持续读取 socket:一旦观察到 `Close` / EOF / 传输错误,立即把连接标记为 `closed`。
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;匹配的 `Pong` 必须在 deadline 内返回。
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;收到任意入站帧即解除本次心跳 deadline。
//! - 可选 `liveness_timeout`:长时间无任何入站活动时判定连接失活并退出。
//!
//! 因此空闲连接的“死没死”在后台被实时感知;复用方只需零成本读取 [`PumpedWebSocket::is_closed`],
@@ -57,7 +57,7 @@ impl PumpLogContext {
pub(crate) struct PumpKeepalive {
/// 主动 `Ping` 间隔;`None` 表示 pump 不主动 ping(仅被动读取 + 回应上游 ping)。
pub(crate) ping_interval: Option<Duration>,
/// 主动 `Ping` 发出后等待匹配 `Pong` 的 deadline;`None` 表示不校验响应。
/// 主动 `Ping` 发出后等待任意入站帧的 deadline;`None` 表示不校验响应。
pub(crate) ping_timeout: Option<Duration>,
/// 无入站活动多久后判定失活;`None` 表示只靠显式 close/传输错误发现死亡。
pub(crate) liveness_timeout: Option<Duration>,
+25 -1
View File
@@ -74,7 +74,7 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
assert_eq!(bundle.core_provider().name(), "openai");
assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai");
let contributions = bundle.take_worker_contributions();
assert_eq!(contributions.len(), 4);
assert_eq!(contributions.len(), 5);
assert!(
contributions
.iter()
@@ -101,6 +101,30 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
panic!("Desktop release worker must be scheduled");
};
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
for (owner, interval) in [
("openai", Duration::from_secs(30)),
(
"openai-model-catalog",
config.config.quota_refresh_policy().interval(),
),
] {
let schedule = contributions
.iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration)
if registration.id.kind() == WorkerKind::QuotaCatalogHealth
&& registration.id.owner() == owner =>
{
match &registration.runnable {
WorkerRunnable::Scheduled { schedule, .. } => Some(schedule),
WorkerRunnable::Daemon { .. } => None,
}
}
_ => None,
})
.expect("quota/catalog scheduled worker");
assert_eq!(schedule.interval(), interval, "{owner}");
}
assert!(contributions.iter().any(|contribution| {
matches!(
contribution,
@@ -1,6 +1,7 @@
//! OpenAI 额度事实边界与展示快照回归。
mod recovery;
mod refresh_timing;
mod scheduling;
mod slots;
mod snapshot;
@@ -0,0 +1,130 @@
//! 通过 quota 服务验证周期复核与 reset 宽限期,不依赖内部调度状态。
use std::time::Duration;
use super::*;
async fn mount_usage(server: &MockServer, value: serde_json::Value) {
server.reset().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(value))
.mount(server)
.await;
}
async fn wait_for_reset_grace(reset: i64) {
let due = SystemTime::UNIX_EPOCH + Duration::from_secs((reset + 120) as u64);
if let Ok(remaining) = due.duration_since(SystemTime::now()) {
tokio::time::sleep(remaining + Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_quota_timing").await;
let account = store.account("acct_quota_timing").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
// 使用接近宽限期终点的真实窗口,在数秒内走完两个窗口的调度与落库路径。
let short_reset = Utc::now().timestamp() - 115;
let week_reset = short_reset + 3;
let usage = |short_used, short_reset, week_used, week_reset| {
json!({"rate_limit": {
"allowed": false,
"primary_window": {
"used_percent": short_used, "reset_at": short_reset, "limit_window_seconds": 18_000,
},
"secondary_window": {
"used_percent": week_used, "reset_at": week_reset, "limit_window_seconds": 604_800,
},
}})
};
mount_usage(&server, usage(100, short_reset, 100, week_reset)).await;
service
.refresh_account(account.id())
.await
.expect("seed exhaustion");
service.synchronize().await.expect("initial periodic check");
let requests = server.received_requests().await.expect("requests").len();
assert!(
Utc::now().timestamp() < short_reset + 120,
"fixture must precede grace deadline"
);
service.synchronize().await.expect("before grace deadline");
assert_eq!(
server.received_requests().await.expect("requests").len(),
requests
);
mount_usage(&server, usage(0, short_reset + 18_000, 100, week_reset)).await;
wait_for_reset_grace(short_reset).await;
let partial = service.synchronize().await.expect("short reset check");
assert_eq!(partial.exhausted, 1);
assert_eq!(
store
.account("acct_quota_timing")
.expect("account")
.quota()
.reset_at(),
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(week_reset as u64))
);
service
.synchronize()
.await
.expect("next scan before weekly grace deadline");
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
// 周窗口的到期复核仍未恢复时,也不能每轮扫描重复请求。
wait_for_reset_grace(week_reset).await;
assert_eq!(
service
.synchronize()
.await
.expect("weekly reset check")
.exhausted,
1
);
service
.synchronize()
.await
.expect("repeat scan after weekly check");
assert_eq!(server.received_requests().await.expect("requests").len(), 2);
}
#[tokio::test]
async fn periodic_checks_continue_when_reset_is_unknown_or_far_in_the_future() {
for reset in [None, Some(Utc::now().timestamp() + 604_800)] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_quota_periodic").await;
let account = store.account("acct_quota_periodic").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut usage = json!({"rate_limit": {
"allowed": false,
"primary_window": {"used_percent": 100, "limit_window_seconds": 604_800},
}});
if let Some(reset) = reset {
usage["rate_limit"]["primary_window"]["reset_at"] = json!(reset);
}
mount_usage(&server, usage.clone()).await;
service
.refresh_account(account.id())
.await
.expect("seed exhaustion");
mount_usage(&server, usage).await;
assert_eq!(
service
.synchronize()
.await
.expect("periodic check")
.exhausted,
1
);
service.synchronize().await.expect("throttled repeat check");
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
}
}
@@ -172,6 +172,99 @@ async fn recovery_checks_each_exhausted_slot_in_worker_and_manual_refresh() {
}
}
#[tokio::test]
async fn worker_detects_early_resets_without_unlocking_other_exhausted_windows() {
let short_reset = Utc::now().timestamp() + 18_000;
let week_reset = Utc::now().timestamp() + 604_800;
for (name, old_short, old_week, new_short, new_week, recovered) in [
("short_only", Some(100), None, Some(0), None, true),
("weekly_only", None, Some(100), None, Some(0), true),
(
"short_with_weekly_available",
Some(100),
Some(45),
Some(0),
Some(45),
true,
),
(
"weekly_still_exhausted",
Some(100),
Some(100),
Some(0),
Some(100),
false,
),
] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = format!("acct_early_{name}");
create_account(&store, &account_id).await;
let account = store.account(&account_id).expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let window = |used: Option<u64>, reset, seconds| {
used.map(|used| {
json!({
"used_percent": used,
"reset_at": reset,
"limit_window_seconds": seconds,
})
})
};
mount_usage(
&server,
json!({"rate_limit": {
"allowed": false,
"primary_window": window(old_short, short_reset, 18_000),
"secondary_window": window(old_week, week_reset, 604_800),
}}),
)
.await;
let baseline = service
.refresh_account(account.id())
.await
.expect("seed exhaustion");
assert!(baseline.quota().reset_at().expect("future reset") > SystemTime::now());
mount_usage(
&server,
json!({"rate_limit": {
"allowed": recovered,
"primary_window": window(new_short, short_reset + 18_000, 18_000),
"secondary_window": window(new_week, if new_week == Some(0) {
week_reset + 604_800
} else {
week_reset
}, 604_800),
}}),
)
.await;
let summary = service
.synchronize()
.await
.expect("periodic early reset check");
assert_eq!(summary.updated, u64::from(recovered), "{name}");
assert_eq!(summary.exhausted, u64::from(!recovered), "{name}");
assert_eq!(
store
.account(&account_id)
.expect("updated account")
.quota()
.is_exhausted(),
!recovered,
"{name}"
);
// 未恢复的周窗口仍在未来,也必须保留复核节流;恢复后则退出定时复核。
service.synchronize().await.expect("next worker cycle");
assert_eq!(
server.received_requests().await.expect("requests").len(),
1,
"{name}"
);
}
}
#[tokio::test]
async fn partial_recovery_survives_restart_missing_windows_and_changed_roles() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1001,6 +1001,18 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
),
(
ImageRequestKind::Generation,
"/codex/images/generations",
br#"{"model":"gpt-image-2.5-flare","prompt":"a lighthouse","quality":"xhigh"}"#.as_slice(),
br#"{"created":1788900000,"data":[{"b64_json":"AAEC"}],"quality":"xhigh"}"#.as_slice(),
),
(
ImageRequestKind::Edit,
"/codex/images/edits",
br#"{"model":"gpt-image-2.5-sunburst","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","quality":"max"}"#.as_slice(),
br#"{"created":1788900001,"data":[{"b64_json":"AwQF"}],"quality":"max"}"#.as_slice(),
),
];
for (_, endpoint, body, response_body) in &cases {
Mock::given(method("POST"))
@@ -1225,6 +1237,10 @@ async fn image_prices_should_use_modality_rates_and_precede_delivery() {
for (kind, model) in [
(ImageRequestKind::Generation, "gpt-image-2"),
(ImageRequestKind::Edit, "gpt-image-2-2026-04-21"),
(ImageRequestKind::Generation, "gpt-image-2.5-sunburst"),
(ImageRequestKind::Edit, "gpt-image-2.5-sunburst-2026-09-08"),
(ImageRequestKind::Generation, "gpt-image-2.5-flare"),
(ImageRequestKind::Edit, "gpt-image-2.5-flare-2026-09-08"),
] {
let events = image_metering_events(kind, &response, model).await;
let costs = events
@@ -1240,9 +1256,13 @@ async fn image_prices_should_use_modality_rates_and_precede_delivery() {
})
})
.collect::<Vec<_>>();
assert_eq!(costs.len(), 1);
assert_eq!(costs.len(), 1, "model={model}");
let (cost_index, cost) = costs[0];
assert_eq!(cost.total().amount().scaled(), expected_ticks);
assert_eq!(
cost.total().amount().scaled(),
expected_ticks,
"model={model}"
);
assert_eq!(cost.into_estimate().source().as_str(), "calculated");
let wire_index = events
.iter()
@@ -1272,6 +1292,9 @@ async fn image_prices_should_remain_unknown_when_modality_or_model_is_uncertain(
let mut cases = vec![
("gpt-image-future", valid.clone()),
("gpt-image-2-future", valid.clone()),
("gpt-image-2.5", valid.clone()),
("gpt-image-2.5-sunburst-future", valid.clone()),
("gpt-image-2.5-flare-future", valid.clone()),
("gpt-image-2", json!(null)),
(
"gpt-image-2",
@@ -4820,7 +4843,7 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
drop(stream);
assert_eq!(affinity.binding_count(), 1);
assert_eq!(observed_service_tier.as_deref(), Some("priority"));
assert_eq!(observed_service_tier.as_deref(), Some("default"));
assert_eq!(upstream_service_tier.as_deref(), Some("default"));
}
@@ -219,7 +219,7 @@ fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
}
#[test]
fn decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fast_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"default\"}}\n\n",
@@ -236,7 +236,7 @@ fn decoder_should_prefer_requested_service_tier_over_response_tier_for_billing()
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 6_875_000
if cost.total().amount().scaled() == 3_437_500
)));
}
@@ -346,7 +346,7 @@ fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
}
#[test]
fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
let created = websocket_event_to_sse_frame(
r#"{"type":"response.created","response":{"id":"resp_ws_fast_cost","model":"gpt-5.4","service_tier":"default"}}"#,
)
@@ -366,7 +366,7 @@ fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 6_875_000
if cost.total().amount().scaled() == 3_437_500
)));
}
@@ -936,3 +936,96 @@ fn canonical_facts(events: &[ProviderEvent]) -> Vec<&GatewayEvent> {
.flat_map(ProviderEvent::canonical_facts)
.collect()
}
fn pricing_response_cost(
requested: Option<&str>,
actual: Option<&str>,
output: serde_json::Value,
tools: &[serde_json::Value],
) -> Option<u128> {
let mut response = json!({
"id": "resp_pricing", "model": "gpt-6-astra", "status": "completed",
"output": output, "usage": {"input_tokens": 10_000, "output_tokens": 0, "total_tokens": 10_000}
});
if let Some(tier) = actual {
response["service_tier"] = json!(tier);
}
let created = json!({"type": "response.created", "response": {"id": "resp_pricing", "model": "gpt-6-astra"}});
let completed = json!({"type": "response.completed", "response": response});
let body = format!(
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
);
let events = CodexCanonicalDecoder::new("gpt-6-astra")
.with_requested_service_tier(requested)
.with_request_tool_pricing("gpt-6-astra", Some(tools))
.with_raw_sse_passthrough()
.push(body.as_bytes())
.expect("计费不确定时仍须正常透传响应");
assert!(
canonical_facts(&events)
.iter()
.any(|event| matches!(event, GatewayEvent::Completed(_)))
);
canonical_facts(&events)
.into_iter()
.find_map(|event| match event {
GatewayEvent::CalculatedCost(cost) => Some(cost.total().amount().scaled()),
_ => None,
})
}
#[test]
fn billing_should_follow_actual_tier_and_only_fall_back_when_it_is_absent() {
for (requested, actual, expected) in [
(Some("fast"), Some("default"), Some(1_000_000_000)),
(Some("auto"), Some("fast"), Some(2_000_000_000)),
(Some("default"), Some("flex"), Some(500_000_000)),
(Some("fast"), None, Some(2_000_000_000)),
(Some("auto"), None, None),
(Some("default"), Some("future"), None),
] {
assert_eq!(
pricing_response_cost(requested, actual, json!([]), &[]),
expected,
"{requested:?} -> {actual:?}"
);
}
}
#[test]
fn billing_should_add_file_and_web_search_fees_without_tier_markup() {
let output = json!([
{"type": "file_search_call", "id": "fs_1", "status": "completed"},
{"type": "file_search_call", "id": "fs_2", "status": "completed"},
{"type": "web_search_call", "id": "ws_1", "status": "completed", "action": {"type": "search"}}
]);
for (tier, expected) in [("default", 1_150_000_000), ("fast", 2_150_000_000)] {
assert_eq!(
pricing_response_cost(
None,
Some(tier),
output.clone(),
&[json!({"type": "web_search"})]
),
Some(expected)
);
}
}
#[test]
fn billing_should_not_emit_partial_totals_for_unpriced_tool_outputs() {
for output in [
json!([{"type": "code_interpreter_call", "id": "ci_1", "status": "completed"}]),
json!([{"type": "shell_call", "id": "sh_1", "status": "completed"}]),
json!([{"type": "image_generation_call", "id": "ig_1", "status": "completed"}]),
json!([{"type": "future_paid_tool_call", "id": "tool_1"}]),
json!([{"type": "file_search_call", "id": "fs_1", "status": "failed"}]),
json!({"malformed": true}),
] {
assert_eq!(
pricing_response_cost(None, None, output.clone(), &[]),
None,
"{output}"
);
}
}
@@ -239,6 +239,90 @@ async fn exact_limit_success_body_should_be_accepted() {
assert!(usage["rate_limit"].is_object());
}
#[test]
fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
// 每个用例包含一个普通输入 token 和一个输出 token,金额单位为 USD ticks。
for (model, expected) in [
("gpt-5.1-codex-mini", 22_500),
("gpt-5.3-chat-latest", 157_500),
("gpt-5.6-cyber", 875_000),
("gpt-5.5-cyber", 875_000),
("gpt-daybreak-red-latest", 875_000),
("gpt-daybreak-blue-latest", 350_000),
("chat-latest", 350_000),
("gpt-5.1-codex-max", 112_500),
("gpt-5.2-codex", 157_500),
("gpt-5.4-2026-03-05", 175_000),
(" OpenAI/GPT-4o-2024-08-06 ", 125_000),
("gpt-4o-2024-05-13", 200_000),
("gpt-3.5-turbo-0125", 20_000),
("gpt-3.5-turbo-1106", 30_000),
] {
let billing =
openai_billing_breakdown(model, billing_usage(1, 1, 0, 0), None).expect(model);
assert_eq!(
billing.total_amount().amount().scaled(),
expected,
"{model}"
);
}
}
#[test]
fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
for model in [
"gpt-6-astra-future",
"gpt-6-astra-2099-01-01",
"gpt-5.6-sol-wm",
"gpt-5.6-cyber-future",
"gpt-5.4-cyber",
"gpt-5.3-codex-spark",
"gpt-4.5-preview",
"gpt-4-32k",
"gpt-5.4:custom",
"gpt-5.4.1",
] {
assert!(
openai_billing_breakdown(model, billing_usage(1, 1, 0, 0), None).is_none(),
"{model}"
);
}
for (model, input, tier) in [
("gpt-5.6-cyber", 272_001, None),
("gpt-5.5-cyber", 272_001, None),
("gpt-5.6-cyber", 1, Some("fast")),
("gpt-5.1-codex-mini", 1, Some("flex")),
("gpt-6-astra", 1, Some("auto")),
("gpt-6-astra", 1, Some("ultrafast")),
] {
assert!(
openai_billing_breakdown(model, billing_usage(input, 1, 0, 0), tier).is_none(),
"{model} {tier:?}"
);
}
}
#[test]
fn billing_should_reject_overlapping_or_overflowing_cache_counts() {
for (input, cached, written) in [(100, 20, 90), (100, 0, 101), (u64::MAX, u64::MAX, 1)] {
assert!(
openai_billing_breakdown(
"gpt-6-astra",
billing_usage(input, 0, cached, written),
None
)
.is_none()
);
}
let cyber = openai_billing_breakdown("gpt-5.6-cyber", billing_usage(100, 10, 20, 10), None)
.expect("已公开的 Cyber 缓存写价格");
assert_eq!(
cyber.cache_write_price_per_million().amount().to_string(),
"15.6250000000"
);
assert_eq!(cyber.total_amount().amount().scaled(), 18_062_500);
}
#[tokio::test]
async fn fetch_should_use_wham_usage_headers_only() {
let server = MockServer::start().await;
+3 -2
View File
@@ -56,7 +56,7 @@ use crate::credential::{
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
};
use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown_with_tier};
const PENDING_SCHEMA_VERSION: u64 = 3;
const PENDING_TTL: TimeDelta = TimeDelta::minutes(30);
@@ -390,11 +390,12 @@ impl ProviderAdmin for XaiAdminProvider {
else {
return Ok(None);
};
let Some(breakdown) = grok_billing_breakdown(
let Some(breakdown) = grok_billing_breakdown_with_tier(
&input.upstream_model_id,
input_tokens,
output_tokens,
input.cached_tokens.unwrap_or_default(),
input.service_tier.as_deref(),
) else {
return Ok(None);
};
+1 -1
View File
@@ -79,7 +79,7 @@ pub use transport::{
MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OfficialGrokEndpointPolicy,
ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport,
SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers, grok_billing_breakdown,
parse_grok_billing, parse_grok_model_catalog,
grok_billing_breakdown_with_tier, parse_grok_billing, parse_grok_model_catalog,
};
/// xAI 初始化后交给组装根的最小能力集。
@@ -185,7 +185,8 @@ pub(super) fn cold_compaction_http_sse_stream(
}
}
};
yield ProviderEvent::observation(accepted.observation);
let mut observation = accepted.observation;
yield ProviderEvent::observation(observation.clone());
let mut body = accepted.response.into_body();
let mut canonical = GrokCanonicalDecoder::new(upstream_model.as_str());
@@ -251,6 +252,10 @@ pub(super) fn cold_compaction_http_sse_stream(
}
}
if let Some(tier) = canonical.response_service_tier() {
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation);
}
let started = facts
.started
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
@@ -460,7 +465,7 @@ pub(super) fn cold_http_sse_stream(
}
};
let observation = xai_response_observation(&response)?;
let mut observation = xai_response_observation(&response)?;
let base_timings = observation.timings();
let mut first_token_ms: Option<u64> = None;
yield ProviderEvent::observation(observation.clone());
@@ -529,6 +534,14 @@ pub(super) fn cold_http_sse_stream(
}),
);
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation.clone().with_timings(
ProviderResponseTimings { first_token_ms, ..base_timings },
));
}
let completed = events
.iter()
.flat_map(ProviderEvent::canonical_facts)
@@ -581,6 +594,14 @@ pub(super) fn cold_http_sse_stream(
}),
);
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation.clone().with_timings(
ProviderResponseTimings { first_token_ms, ..base_timings },
));
}
attach_xai_session_update(&mut final_events, &mut session_capture)?;
if let Some(capture) = reasoning_replay_capture.as_mut() {
capture.observe(&final_events);
@@ -38,6 +38,20 @@ struct ModelPricing {
long: TokenRates,
}
// 价格来源:https://docs.x.ai/developers/pricing,核验日期 2026-09-09。
const GROK_46_PRICING: ModelPricing = ModelPricing {
short: TokenRates {
input_ticks: 20_000,
cached_input_ticks: 5_000,
output_ticks: 60_000,
},
long: TokenRates {
input_ticks: 40_000,
cached_input_ticks: 10_000,
output_ticks: 120_000,
},
};
const GROK_45_PRICING: ModelPricing = ModelPricing {
short: TokenRates {
input_ticks: 20_000,
@@ -85,6 +99,23 @@ pub fn grok_billing_breakdown(
output_tokens: u64,
cached_tokens: u64,
) -> Option<CalculatedCostBreakdown> {
grok_billing_breakdown_with_tier(model, input_tokens, output_tokens, cached_tokens, None)
}
/// 按响应确认的实际档位计算 xAI Token 费用;未知档位不估算。
#[must_use]
pub fn grok_billing_breakdown_with_tier(
model: &str,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
let (tier, multiplier) = match service_tier.map(str::trim) {
None | Some("default" | "standard") => ("default", 1_u128),
Some("priority") => ("priority", 2),
Some(_) => return None,
};
let pricing = model_pricing(model)?;
let rates = if input_tokens >= LONG_CONTEXT_THRESHOLD {
pricing.long
@@ -101,21 +132,21 @@ pub fn grok_billing_breakdown(
.checked_add(output_amount_ticks)?;
Some(CalculatedCostBreakdown::new(
CalculatedCostAmounts::new(
usd_money(input_amount_ticks)?,
usd_money(output_amount_ticks)?,
usd_money(cache_read_amount_ticks)?,
usd_money(input_amount_ticks.checked_mul(multiplier)?)?,
usd_money(output_amount_ticks.checked_mul(multiplier)?)?,
usd_money(cache_read_amount_ticks.checked_mul(multiplier)?)?,
usd_money(0)?,
usd_money(standard_amount_ticks)?,
usd_money(standard_amount_ticks)?,
usd_money(standard_amount_ticks.checked_mul(multiplier)?)?,
),
CalculatedCostRates::new(
usd_price_per_million(rates.input_ticks)?,
usd_price_per_million(rates.output_ticks)?,
usd_price_per_million(rates.cached_input_ticks)?,
usd_price_per_million(rates.input_ticks.checked_mul(multiplier)?)?,
usd_price_per_million(rates.output_ticks.checked_mul(multiplier)?)?,
usd_price_per_million(rates.cached_input_ticks.checked_mul(multiplier)?)?,
usd_money(0)?,
),
Some("default".to_owned()),
100,
Some(tier.to_owned()),
if multiplier == 2 { 200 } else { 100 },
))
}
@@ -144,6 +175,8 @@ pub struct GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet<u32>,
usage_emitted: bool,
output_start_seen: bool,
response_service_tier: Option<String>,
requires_provider_cost: bool,
}
impl GrokCanonicalDecoder {
@@ -159,6 +192,8 @@ impl GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet::new(),
usage_emitted: false,
output_start_seen: false,
response_service_tier: None,
requires_provider_cost: false,
}
}
@@ -168,10 +203,30 @@ impl GrokCanonicalDecoder {
pub fn for_request(fallback_model: impl Into<String>, request: &GrokResponsesRequest) -> Self {
Self {
response_transform: request.response_transform(),
requires_provider_cost: request.body().get("tool_choice").and_then(Value::as_str)
!= Some("none")
&& request
.body()
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| {
tools.iter().any(|tool| {
!matches!(
tool.get("type").and_then(Value::as_str),
Some("function" | "custom" | "mcp")
)
})
}),
..Self::new(fallback_model)
}
}
/// 上游报告的实际服务档位,供费用与持久观测共用。
#[must_use]
pub fn response_service_tier(&self) -> Option<&str> {
self.response_service_tier.as_deref()
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<ProviderEvent>, ProviderError> {
let events = self.decoder.push(chunk).map_err(protocol_error)?;
self.decode(events)
@@ -259,6 +314,16 @@ impl GrokCanonicalDecoder {
continue;
};
let event_type = event_type.to_owned();
// 工具转换可能隐藏注入的调用,计费事实必须从转换前的上游事件读取。
if let Some(response) = value.get("response") {
if let Some(tier) = response.get("service_tier").and_then(Value::as_str) {
self.response_service_tier = Some(tier.trim().to_owned());
}
self.requires_provider_cost |= !token_only_response(response);
}
if let Some(item) = value.get("item") {
self.requires_provider_cost |= !token_only_output(item);
}
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
let Ok(transformed) = self
.response_transform
@@ -590,7 +655,10 @@ impl GrokCanonicalDecoder {
.to_owned();
if let Some(cost) = provider_reported_cost(response)? {
output.push(GatewayEvent::ProviderCost(cost));
} else if let Some(cost) = usage.and_then(|usage| calculated_cost(response, &model, usage))
} else if !self.requires_provider_cost
&& let Some(cost) = usage.and_then(|usage| {
calculated_cost(response, &model, usage, self.response_service_tier())
})
{
output.push(GatewayEvent::CalculatedCost(cost));
}
@@ -729,29 +797,71 @@ fn provider_reported_cost(response: &Value) -> Result<Option<ProviderReportedCos
return Ok(None);
};
let ticks = value.as_u64().ok_or_else(protocol_error_marker)?;
if ticks == 0 {
return Ok(None);
}
// 明确返回的零费用也是上游账单事实,不能当作缺失而改用本地估算。
ProviderReportedCost::from_usd_ticks(u128::from(ticks))
.map(Some)
.map_err(protocol_error)
}
fn calculated_cost(response: &Value, model: &str, usage: TokenUsage) -> Option<CalculatedCost> {
if !billable_usage_is_complete(response, usage) {
// cost_in_usd_ticks 已包含服务端工具费;缺少该字段时,只有全部按 token
// 计费的响应才能确定总价。
fn token_only_output(item: &Value) -> bool {
matches!(
item.get("type").and_then(Value::as_str),
Some(
"message"
| "reasoning"
| "function_call"
| "custom_tool_call"
| "mcp_call"
| "mcp_list_tools"
| "mcp_approval_request"
| "compaction"
)
)
}
fn token_only_response(response: &Value) -> bool {
let empty = |value: &Value| value.as_object().is_some_and(serde_json::Map::is_empty);
response.get("tool_usage").is_none_or(empty)
&& response
.pointer("/usage/server_side_tool_usage")
.is_none_or(empty)
&& response
.pointer("/usage/num_server_side_tool_calls")
.is_none_or(|value| value.as_u64() == Some(0))
&& response.get("output").is_none_or(|value| {
value
.as_array()
.is_some_and(|items| items.iter().all(token_only_output))
})
}
fn calculated_cost(
response: &Value,
model: &str,
usage: TokenUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCost> {
if !billable_usage_is_complete(response, usage)
|| usage.image_input_tokens > 0
|| usage.image_output_tokens > 0
{
return None;
}
let breakdown = grok_billing_breakdown(
let breakdown = grok_billing_breakdown_with_tier(
model,
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
service_tier,
)?;
Some(breakdown.calculated_cost())
}
fn model_pricing(model: &str) -> Option<ModelPricing> {
match model {
"grok-4.6" | "grok-4.6-latest" => Some(GROK_46_PRICING),
"grok-4.5" | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest" => {
Some(GROK_45_PRICING)
}
@@ -11,7 +11,9 @@ pub(crate) mod profile;
mod request;
mod session;
pub use canonical::{GrokCanonicalDecoder, grok_billing_breakdown};
pub use canonical::{
GrokCanonicalDecoder, grok_billing_breakdown, grok_billing_breakdown_with_tier,
};
pub use catalog::{
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError,
GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, GrokBillingTransportError,
@@ -158,6 +158,26 @@ async fn xai_admin_provider_validates_known_billing_breakdown() {
assert_eq!(billing.total_amount.amount.as_str(), "0.0002175");
assert_eq!(billing.input_price_per_million.amount.as_str(), "2");
assert_eq!(billing.output_price_per_million.amount.as_str(), "6");
let priority = admin
.calculated_billing(&ProviderBillingInput {
upstream_model_id: "grok-4.6".to_owned(),
service_tier: Some("priority".to_owned()),
input_tokens: Some(100),
output_tokens: Some(10),
cached_tokens: Some(20),
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "0.00046".parse().expect("amount"),
},
})
.expect("计费明细")
.expect("已知的 Priority 价格");
assert_eq!(priority.service_tier.as_deref(), Some("priority"));
assert_eq!(priority.standard_amount.amount.as_str(), "0.00023");
assert_eq!(priority.input_price_per_million.amount.as_str(), "4");
assert_eq!(priority.multiplier_percent, 200);
}
#[tokio::test]
@@ -1239,6 +1239,56 @@ async fn transport_error_observation_should_retain_available_metrics() {
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
}
#[tokio::test]
async fn billing_observation_should_persist_the_final_response_tier_before_completion() {
for (actual, expected_cost) in [("priority", 400_000_000), ("default", 200_000_000)] {
let body = format!(
concat!(
"event: response.created\ndata: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_tier\",\"model\":\"grok-4.6\",\"service_tier\":\"priority\"}}}}\n\n",
"event: response.completed\ndata: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_tier\",\"model\":\"grok-4.6\",\"service_tier\":\"{}\",\"status\":\"completed\",\"output\":[],\"usage\":{{\"input_tokens\":10000,\"output_tokens\":0}}}}}}\n\n"
),
actual
);
let transport =
StubInferenceTransport::sequence([InferenceMode::SuccessBody(body.into_bytes())]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("provider stream");
let mut tier = None;
let mut cost = None;
let mut completed = false;
while let Some(event) = stream.next().await {
let event = event.expect("provider event");
if let Some(value) = event
.response_observation()
.and_then(|value| value.service_tier())
{
tier = Some(value.to_owned());
}
for fact in event.canonical_facts() {
match fact {
GatewayEvent::CalculatedCost(value) => {
cost = Some(value.total().amount().scaled())
}
GatewayEvent::Completed(_) => completed = true,
_ => {}
}
}
if completed {
break;
}
}
assert!(completed);
assert_eq!(tier.as_deref(), Some(actual));
assert_eq!(cost, Some(expected_cost));
}
}
#[tokio::test]
async fn compaction_stream_should_emit_openai_wire_and_keep_only_metering_canonical() {
let summary = valid_compaction_summary("validated summary");
@@ -5,7 +5,10 @@ use gateway_core::policy::ClientApiKeyId;
use gateway_protocol::openai::sse::{encode_sse_event, encode_sse_event_with_metadata};
use serde_json::Value;
use provider_xai::{GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown};
use provider_xai::{
GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown,
grok_billing_breakdown_with_tier,
};
fn terminal_cost_events(
model: &str,
@@ -231,17 +234,139 @@ fn billing_breakdown_should_match_the_calculated_terminal_cost() {
#[test]
fn decoder_should_price_official_grok_45_build_free_variant() {
let events = terminal_cost_events("grok-4.5-build-free", 201, 0, 76, Some(0));
let events = terminal_cost_events("grok-4.5-build-free", 201, 0, 76, None);
assert_eq!(calculated_cost_ticks(&events), Some(8_580_000));
}
#[test]
fn zero_provider_cost_should_fall_back_to_calculated_cost() {
fn zero_provider_cost_should_remain_an_authoritative_zero() {
let events = terminal_cost_events("grok-4.5", 1, 0, 1, Some(0));
assert_eq!(calculated_cost_ticks(&events), Some(80_000));
assert_eq!(provider_cost_ticks(&events), None);
assert_eq!(calculated_cost_ticks(&events), None);
assert_eq!(provider_cost_ticks(&events), Some(0));
}
#[test]
fn billing_should_price_grok_46_cache_and_priority_at_the_context_boundary() {
// 短、长上下文分别检查普通输入、缓存、输出的单价,Priority 只采用已确认档位。
for (input, tier, expected) in [
(199_999, None, ["2", "0.5", "6"]),
(200_000, None, ["4", "1", "12"]),
(199_999, Some("priority"), ["4", "1", "12"]),
(200_000, Some("priority"), ["8", "2", "24"]),
] {
let billing = grok_billing_breakdown_with_tier("grok-4.6", input, 10, 20, tier)
.expect("Grok 4.6 价格");
let rates = [
billing.input_price_per_million(),
billing.cache_read_price_per_million(),
billing.output_price_per_million(),
]
.map(|money| {
money
.amount()
.to_string()
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
});
assert_eq!(rates, expected);
}
assert!(grok_billing_breakdown_with_tier("grok-4.6", 10, 1, 0, Some("future")).is_none());
let priority = grok_billing_breakdown_with_tier("grok-4.6", 100, 10, 20, Some("priority"))
.expect("Priority 价格");
assert_eq!(priority.standard_amount().amount().scaled(), 2_300_000);
assert_eq!(priority.total_amount().amount().scaled(), 4_600_000);
assert_eq!(priority.multiplier_percent(), 200);
}
fn pricing_events(
mut response: Value,
request: Option<&GrokResponsesRequest>,
) -> Vec<GatewayEvent> {
response["id"] = serde_json::json!("resp_pricing");
response["model"] = serde_json::json!("grok-4.6");
response["status"] = serde_json::json!("completed");
let created = serde_json::json!({"type": "response.created", "response": {"id": "resp_pricing", "model": "grok-4.6", "service_tier": "priority"}});
let completed = serde_json::json!({"type": "response.completed", "response": response});
let body = format!(
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
);
let mut decoder = request.map_or_else(
|| GrokCanonicalDecoder::new("grok-4.6"),
|request| GrokCanonicalDecoder::for_request("grok-4.6", request),
);
let events = decoder
.push(body.as_bytes())
.expect("计费未知时仍应交付响应");
let facts: Vec<_> = events
.into_iter()
.flat_map(|event| event.into_parts().0)
.collect();
assert!(
facts
.iter()
.any(|event| matches!(event, GatewayEvent::Completed(_)))
);
facts
}
#[test]
fn billing_should_use_the_final_response_tier_and_preserve_provider_cost() {
for (tier, ticks, calculated, provider) in [
("priority", None, Some(400_000_000), None),
("default", None, Some(200_000_000), None),
("future", None, None, None),
("priority", Some(0), None, Some(0)),
("priority", Some(123), None, Some(123)),
] {
let mut response = serde_json::json!({"service_tier": tier, "output": [], "usage": {"input_tokens": 10_000, "output_tokens": 0}});
if let Some(ticks) = ticks {
response["usage"]["cost_in_usd_ticks"] = serde_json::json!(ticks);
}
let events = pricing_events(response, None);
assert_eq!(
(calculated_cost_ticks(&events), provider_cost_ticks(&events)),
(calculated, provider),
"{tier}"
);
}
}
#[test]
fn billing_should_require_provider_totals_for_server_tools() {
for kind in [
"web_search_call",
"x_search_call",
"code_interpreter_call",
"file_search_call",
"image_generation_call",
"future_tool_call",
] {
let response = serde_json::json!({"output": [{"type": kind, "id": "tool_1", "status": "completed"}], "usage": {"input_tokens": 100, "output_tokens": 1}});
assert_eq!(
calculated_cost_ticks(&pricing_events(response, None)),
None,
"{kind}"
);
}
let request =
tool_request(serde_json::json!({"input": "查资料", "tools": [{"type": "web_search"}]}));
// 工具结果可能被上游省略,允许调用收费工具时不能仅靠空 output 认定没有附加费。
let mut response =
serde_json::json!({"output": [], "usage": {"input_tokens": 100, "output_tokens": 1}});
assert_eq!(
calculated_cost_ticks(&pricing_events(response.clone(), Some(&request))),
None
);
response["usage"]["cost_in_usd_ticks"] = serde_json::json!(0);
assert_eq!(
provider_cost_ticks(&pricing_events(response, Some(&request))),
Some(0)
);
let response = serde_json::json!({"output": [], "usage": {"input_tokens": 100, "output_tokens": 1, "num_server_side_tool_calls": 1}});
assert_eq!(calculated_cost_ticks(&pricing_events(response, None)), None);
}
#[test]
+1 -10
View File
@@ -1,10 +1 @@
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
93219760c733616e7bba9a8933c207b26752b2ebf7e6edfe1ef42ba227c50a7d 0002_runtime_pressure_observability.sql
1b6ffab434c754814aebb0eee3b650bd9770010059472bccf5bc856b319880ca 0003_client_min_versions.sql
22f912ab3e196de7983ef50a1dba5aee340bbea82bdd15d05863e87012000c83 0004_ops_error_send_state.sql
194d7b4bedd83f0d76428a21699b0e946449bf2ba61a0fdf8710ebfd421af8b2 0005_openai_continuation_recovery_observability.sql
3b4bfd5e3f5135fa2fad1088b0c2a287861e1250d66af31dbd40dc890751b567 0006_openai_session_transport_recovery.sql
8c0ec7fab80d95dfd5a370c70706c36ac5907bb5afea0cf1886196f21fd8317d 0007_request_diagnostics.sql
374f0d8fa8b671ca845d085f7d173b2aa57320e7e5c41e0398329f0bdd89506e 0008_persisted_fact_integrity.sql
de20c9c9c57fcf0371a2245a3de9a57d72ab75d1c08ffff92f76b2b270d6a72c 0009_trim_observability_indexes.sql
661aba0469d4b9e4626e9e9331015bb2120cc948e8d15b1ca83a394a5007e11a 0010_remove_unused_event_aggregation.sql
b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.sql
+178 -36
View File
@@ -33,18 +33,18 @@ create table admin_audit_events (
constraint admin_audit_events_actor_fk foreign key (actor_admin_user_id)
references admin_users (id)
on update restrict
on delete set null
on delete set null,
constraint admin_audit_events_actor_identity_ck check (
actor_admin_user_id is null
or (actor_kind = 'admin_session' and actor_ref = 'admin:' || actor_admin_user_id)
)
);
create index admin_audit_events_actor_idx
on admin_audit_events (actor_admin_user_id, created_at desc, id desc)
on admin_audit_events (actor_admin_user_id)
where actor_admin_user_id is not null;
create index admin_audit_events_created_idx
on admin_audit_events (created_at desc, id desc);
create index admin_audit_events_entity_idx
on admin_audit_events (entity_kind, entity_ref, created_at desc, id desc);
create index admin_audit_events_actor_ref_idx
on admin_audit_events (actor_ref, created_at desc, id desc);
-- 客户端 API 密钥、鉴权范围与限额。
create table client_api_keys (
@@ -70,8 +70,6 @@ create table client_api_keys (
create index client_api_keys_created_idx
on client_api_keys (created_at desc, id desc);
create index client_api_keys_last_used_idx
on client_api_keys (last_used_at desc, id desc);
-- 全局运行参数与配置版本。
create table runtime_settings (
@@ -88,6 +86,8 @@ create table runtime_settings (
ops_event_retention_days bigint not null default 30,
audit_retention_days bigint not null default 90,
updated_at timestamptz not null,
min_codex_desktop_version text,
min_codex_cli_version text,
constraint runtime_settings_singleton_ck check (id = 1),
constraint runtime_settings_revision_ck check (config_revision > 0),
constraint runtime_settings_refresh_ck check (
@@ -107,6 +107,24 @@ create table runtime_settings (
usage_retention_days >= 31
and ops_event_retention_days > 0
and audit_retention_days > 0
),
constraint runtime_settings_client_versions_ck check (
(
min_codex_desktop_version is null
or (
octet_length(min_codex_desktop_version) between 1 and 64
and min_codex_desktop_version = btrim(min_codex_desktop_version)
and min_codex_desktop_version !~ '[[:cntrl:]]'
)
)
and (
min_codex_cli_version is null
or (
octet_length(min_codex_cli_version) between 1 and 64
and min_codex_cli_version = btrim(min_codex_cli_version)
and min_codex_cli_version !~ '[[:cntrl:]]'
)
)
)
);
@@ -224,13 +242,8 @@ create index provider_accounts_runtime_idx
on provider_accounts (provider_kind, enabled, id);
create index provider_accounts_credential_state_idx
on provider_accounts (credential_state, id);
create index provider_accounts_quota_access_idx
on provider_accounts (quota_access_state, quota_reset_at, id);
create index provider_accounts_access_expiry_idx
on provider_accounts (access_token_expires_at, id);
create index provider_accounts_refresh_due_idx
on provider_accounts (next_refresh_at, id)
where enabled and has_refresh_token and next_refresh_at is not null;
create index provider_accounts_email_idx
on provider_accounts (provider_kind, lower(email))
where email is not null;
@@ -266,8 +279,6 @@ create table account_groups (
create unique index account_groups_name_uq
on account_groups (lower(name));
create index account_groups_list_idx
on account_groups (enabled, created_at desc, id desc);
create table account_group_accounts (
account_group_id text not null,
@@ -376,6 +387,25 @@ create table model_requests (
routing_scope text not null,
routing_group_refs text[] not null default '{}',
routing_group_names_snapshot jsonb not null default '[]'::jsonb,
admission_decision_ms bigint,
account_selection_wait_ms bigint,
capacity_used_slots bigint,
capacity_total_slots bigint,
raw_upstream_error text,
continuation_affinity_hash text,
continuation_previous_response_id_hash text,
continuation_requested boolean not null default false,
continuation_unavailable_reason text,
upstream_connection_id text,
upstream_connection_exit_reason text,
upstream_connection_age_ms bigint,
upstream_connection_idle_ms bigint,
recovery_request_id text,
recovered_at timestamptz,
recovery_attempt_count integer not null default 0,
recovery_retry_delay_ms bigint,
recovery_total_latency_ms bigint,
diagnostic_trace_json jsonb,
constraint model_requests_client_ref_ck check (
client_api_key_id is null or client_api_key_id = client_api_key_ref
),
@@ -523,18 +553,97 @@ create table model_requests (
constraint model_requests_account_fk foreign key (provider_account_id)
references provider_accounts (id)
on update restrict
on delete set null
on delete set null,
constraint model_requests_runtime_pressure_ck check (
(admission_decision_ms is null or admission_decision_ms >= 0)
and (account_selection_wait_ms is null or account_selection_wait_ms >= 0)
and (
(capacity_used_slots is null and capacity_total_slots is null)
or (
capacity_used_slots >= 0
and capacity_total_slots > 0
and capacity_used_slots <= capacity_total_slots
)
)
),
constraint model_requests_continuation_hashes_ck check (
(continuation_affinity_hash is null
or continuation_affinity_hash ~ '^[0-9a-f]{64}$')
and (continuation_previous_response_id_hash is null
or continuation_previous_response_id_hash ~ '^[0-9a-f]{64}$')
and (continuation_requested
or continuation_previous_response_id_hash is null)
),
constraint model_requests_continuation_reason_ck check (
continuation_unavailable_reason is null
or (
octet_length(continuation_unavailable_reason) between 1 and 64
and continuation_unavailable_reason ~ '^[a-z][a-z0-9_]*$'
)
),
constraint model_requests_upstream_connection_ck check (
(
upstream_connection_id is null
and upstream_connection_exit_reason is null
and upstream_connection_age_ms is null
and upstream_connection_idle_ms is null
)
or (
octet_length(upstream_connection_id) between 1 and 128
and upstream_connection_id !~ '[[:cntrl:]]'
and octet_length(upstream_connection_exit_reason) between 1 and 64
and upstream_connection_exit_reason ~ '^[a-z][a-z0-9_]*$'
and upstream_connection_age_ms >= 0
and upstream_connection_idle_ms between 0 and upstream_connection_age_ms
)
),
constraint model_requests_recovery_ck check (
recovery_attempt_count >= 0
and (
(
recovered_at is null
and recovery_request_id is null
and recovery_retry_delay_ms is null
and recovery_total_latency_ms is null
)
or (
recovered_at is not null
and recovered_at >= completed_at
and recovery_request_id is not null
and octet_length(recovery_request_id) between 1 and 128
and recovery_attempt_count > 0
and recovery_retry_delay_ms >= 0
and recovery_total_latency_ms >= recovery_retry_delay_ms
)
)
),
constraint model_requests_diagnostic_trace_object
check (diagnostic_trace_json is null or jsonb_typeof(diagnostic_trace_json) = 'object'),
constraint model_requests_fact_completeness_ck check (
(provider_account_id is null or provider_account_ref is not null)
and (cost_source = 'unavailable' or cost_currency is not null)
and num_nonnulls(capacity_used_slots, capacity_total_slots) in (0, 2)
and num_nonnulls(
upstream_connection_id, upstream_connection_exit_reason,
upstream_connection_age_ms, upstream_connection_idle_ms
) in (0, 4)
and (
recovered_at is null
or (
completed_at is not null
and recovery_retry_delay_ms is not null
and recovery_total_latency_ms is not null
)
)
)
);
create index model_requests_client_idx
on model_requests (client_api_key_id, started_at desc, id desc);
on model_requests (client_api_key_id)
where client_api_key_id is not null;
create index model_requests_account_idx
on model_requests (
provider_account_id,
provider_kind,
started_at desc,
id desc
);
on model_requests (provider_account_id)
where provider_account_id is not null;
create index model_requests_started_idx
on model_requests (started_at desc, id desc);
create index model_requests_client_ref_idx
@@ -561,6 +670,37 @@ create index model_requests_retention_idx
create index model_requests_routing_group_refs_idx
on model_requests using gin (routing_group_refs);
create index model_requests_pending_continuation_recovery_idx
on model_requests (
client_api_key_ref,
continuation_affinity_hash,
completed_at desc,
id desc
)
where error_kind = 'continuation_recovery_required'
and recovered_at is null
and continuation_affinity_hash is not null;
create index model_requests_recovery_request_idx
on model_requests (recovery_request_id)
where recovery_request_id is not null;
create index model_requests_pending_ws_transport_recovery_idx
on model_requests (
client_api_key_ref,
continuation_affinity_hash,
completed_at desc,
id desc
)
where provider_kind = 'openai'
and outcome = 'failed'
and error_kind = 'upstream_unavailable'
and upstream_transport = 'websocket'
and upstream_send_state = 'ambiguous'
and downstream_committed_at is null
and recovered_at is null
and continuation_affinity_hash is not null;
-- 运行期警告与错误事件。
create table ops_events (
id text primary key,
@@ -583,8 +723,9 @@ create table ops_events (
upstream_request_id text,
latency_ms bigint,
message text not null,
occurrence_count integer not null default 1,
created_at timestamptz not null,
upstream_send_state text,
raw_upstream_error text,
constraint ops_events_request_attempt_ck check (
(
model_request_id is null
@@ -601,7 +742,6 @@ create table ops_events (
(status_code is null or status_code between 100 and 599)
and (retry_after_ms is null or retry_after_ms >= 0)
and (latency_ms is null or latency_ms >= 0)
and occurrence_count > 0
),
constraint ops_events_account_ref_ck check (
provider_account_id is null or provider_account_id = provider_account_ref
@@ -613,24 +753,23 @@ create table ops_events (
constraint ops_events_account_fk foreign key (provider_account_id)
references provider_accounts (id)
on update restrict
on delete set null
on delete set null,
constraint ops_events_upstream_send_state_ck check (
upstream_send_state is null
or upstream_send_state in ('not_sent', 'sent', 'ambiguous')
),
constraint ops_events_account_snapshot_present_ck check (
provider_account_id is null or provider_account_ref is not null
)
);
create index ops_events_request_idx
on ops_events (model_request_id, attempt_index, id);
create index ops_events_account_idx
on ops_events (
provider_account_id,
provider_kind,
created_at desc,
id desc
);
on ops_events (provider_account_id)
where provider_account_id is not null;
create index ops_events_created_idx
on ops_events (created_at desc, id desc);
create index ops_events_component_idx
on ops_events (component, created_at desc, id desc);
create index ops_events_failure_idx
on ops_events (failure_kind, created_at desc, id desc);
create index ops_events_account_ref_idx
on ops_events (provider_account_ref, created_at desc, id desc)
where provider_account_ref is not null;
@@ -798,6 +937,9 @@ create table backup_records (
-- expires_at 为空,或不早于创建时间(创建时 now + 天数 恒晚于 created_at)。
constraint backup_records_expiry_ck check (
expires_at is null or expires_at >= created_at
),
constraint backup_records_completion_order_ck check (
completed_at is null or completed_at >= started_at
)
);
@@ -1,20 +0,0 @@
-- 请求热路径的准入判定、账号选择等待与账号池容量观测。
alter table model_requests
add column admission_decision_ms bigint,
add column account_selection_wait_ms bigint,
add column capacity_used_slots bigint,
add column capacity_total_slots bigint;
alter table model_requests
add constraint model_requests_runtime_pressure_ck check (
(admission_decision_ms is null or admission_decision_ms >= 0)
and (account_selection_wait_ms is null or account_selection_wait_ms >= 0)
and (
(capacity_used_slots is null and capacity_total_slots is null)
or (
capacity_used_slots >= 0
and capacity_total_slots > 0
and capacity_used_slots <= capacity_total_slots
)
)
);
@@ -1,21 +0,0 @@
alter table runtime_settings
add column min_codex_desktop_version text,
add column min_codex_cli_version text,
add constraint runtime_settings_client_versions_ck check (
(
min_codex_desktop_version is null
or (
octet_length(min_codex_desktop_version) between 1 and 64
and min_codex_desktop_version = btrim(min_codex_desktop_version)
and min_codex_desktop_version !~ '[[:cntrl:]]'
)
)
and (
min_codex_cli_version is null
or (
octet_length(min_codex_cli_version) between 1 and 64
and min_codex_cli_version = btrim(min_codex_cli_version)
and min_codex_cli_version !~ '[[:cntrl:]]'
)
)
);
@@ -1,11 +0,0 @@
-- 为运维错误保留 payload 发送边界与上游错误原文。
alter table model_requests
add column raw_upstream_error text;
alter table ops_events
add column upstream_send_state text,
add column raw_upstream_error text,
add constraint ops_events_upstream_send_state_ck check (
upstream_send_state is null
or upstream_send_state in ('not_sent', 'sent', 'ambiguous')
);
@@ -1,80 +0,0 @@
alter table model_requests
add column continuation_affinity_hash text,
add column continuation_previous_response_id_hash text,
add column continuation_requested boolean not null default false,
add column continuation_unavailable_reason text,
add column upstream_connection_id text,
add column upstream_connection_exit_reason text,
add column upstream_connection_age_ms bigint,
add column upstream_connection_idle_ms bigint,
add column recovery_request_id text,
add column recovered_at timestamptz,
add column recovery_attempt_count integer not null default 0,
add column recovery_retry_delay_ms bigint,
add column recovery_total_latency_ms bigint,
add constraint model_requests_continuation_hashes_ck check (
(continuation_affinity_hash is null
or continuation_affinity_hash ~ '^[0-9a-f]{64}$')
and (continuation_previous_response_id_hash is null
or continuation_previous_response_id_hash ~ '^[0-9a-f]{64}$')
and (continuation_requested
or continuation_previous_response_id_hash is null)
),
add constraint model_requests_continuation_reason_ck check (
continuation_unavailable_reason is null
or (
octet_length(continuation_unavailable_reason) between 1 and 64
and continuation_unavailable_reason ~ '^[a-z][a-z0-9_]*$'
)
),
add constraint model_requests_upstream_connection_ck check (
(
upstream_connection_id is null
and upstream_connection_exit_reason is null
and upstream_connection_age_ms is null
and upstream_connection_idle_ms is null
)
or (
octet_length(upstream_connection_id) between 1 and 128
and upstream_connection_id !~ '[[:cntrl:]]'
and octet_length(upstream_connection_exit_reason) between 1 and 64
and upstream_connection_exit_reason ~ '^[a-z][a-z0-9_]*$'
and upstream_connection_age_ms >= 0
and upstream_connection_idle_ms between 0 and upstream_connection_age_ms
)
),
add constraint model_requests_recovery_ck check (
recovery_attempt_count >= 0
and (
(
recovered_at is null
and recovery_request_id is null
and recovery_retry_delay_ms is null
and recovery_total_latency_ms is null
)
or (
recovered_at is not null
and recovered_at >= completed_at
and recovery_request_id is not null
and octet_length(recovery_request_id) between 1 and 128
and recovery_attempt_count > 0
and recovery_retry_delay_ms >= 0
and recovery_total_latency_ms >= recovery_retry_delay_ms
)
)
);
create index model_requests_pending_continuation_recovery_idx
on model_requests (
client_api_key_ref,
continuation_affinity_hash,
completed_at desc,
id desc
)
where error_kind = 'continuation_recovery_required'
and recovered_at is null
and continuation_affinity_hash is not null;
create index model_requests_recovery_request_idx
on model_requests (recovery_request_id)
where recovery_request_id is not null;
@@ -1,61 +0,0 @@
create index model_requests_pending_ws_transport_recovery_idx
on model_requests (
client_api_key_ref,
continuation_affinity_hash,
completed_at desc,
id desc
)
where provider_kind = 'openai'
and outcome = 'failed'
and error_kind = 'upstream_unavailable'
and upstream_transport = 'websocket'
and upstream_send_state = 'ambiguous'
and downstream_committed_at is null
and recovered_at is null
and continuation_affinity_hash is not null;
with session_transport_recoveries as (
select
prior.id as failed_request_id,
recovered.id as recovery_request_id,
recovered.started_at as recovery_started_at,
recovered.completed_at as recovered_at
from model_requests prior
cross join lateral (
select current.id, current.started_at, current.completed_at
from model_requests current
where current.client_api_key_ref = prior.client_api_key_ref
and current.continuation_affinity_hash = prior.continuation_affinity_hash
and current.provider_kind = prior.provider_kind
and current.outcome = 'succeeded'
and current.upstream_transport = 'http_sse'
and current.completed_at is not null
and current.started_at >= prior.completed_at
and current.started_at <= prior.completed_at + interval '30 seconds'
order by current.started_at, current.id
limit 1
) recovered
where prior.provider_kind = 'openai'
and prior.outcome = 'failed'
and prior.error_kind = 'upstream_unavailable'
and prior.upstream_transport = 'websocket'
and prior.upstream_send_state = 'ambiguous'
and prior.downstream_committed_at is null
and prior.recovered_at is null
and prior.continuation_affinity_hash is not null
)
update model_requests prior
set recovery_attempt_count = prior.recovery_attempt_count + 1,
recovery_request_id = recovery.recovery_request_id,
recovered_at = recovery.recovered_at,
recovery_retry_delay_ms = greatest(
0,
floor(extract(epoch from (recovery.recovery_started_at - prior.completed_at)) * 1000)
)::bigint,
recovery_total_latency_ms = greatest(
0,
floor(extract(epoch from (recovery.recovered_at - prior.completed_at)) * 1000)
)::bigint
from session_transport_recoveries recovery
where prior.id = recovery.failed_request_id
and prior.recovered_at is null;
@@ -1,5 +0,0 @@
-- Bounded request evidence is retained with its owning model request.
-- NULL identifies historical requests for which no trace was captured.
alter table model_requests add column diagnostic_trace_json jsonb;
alter table model_requests add constraint model_requests_diagnostic_trace_object
check (diagnostic_trace_json is null or jsonb_typeof(diagnostic_trace_json) = 'object');
@@ -1,41 +0,0 @@
-- Existing value checks also need explicit presence checks: CHECK accepts NULL.
alter table model_requests
add constraint model_requests_fact_completeness_ck check (
(provider_account_id is null or provider_account_ref is not null)
and (cost_source = 'unavailable' or cost_currency is not null)
and num_nonnulls(capacity_used_slots, capacity_total_slots) in (0, 2)
and num_nonnulls(
upstream_connection_id, upstream_connection_exit_reason,
upstream_connection_age_ms, upstream_connection_idle_ms
) in (0, 4)
and (
recovered_at is null
or (
completed_at is not null
and recovery_retry_delay_ms is not null
and recovery_total_latency_ms is not null
)
)
);
alter table ops_events
add constraint ops_events_account_snapshot_present_ck check (
provider_account_id is null or provider_account_ref is not null
);
alter table backup_records
add constraint backup_records_completion_order_ck check (
completed_at is null or completed_at >= started_at
);
update admin_audit_events
set actor_ref = 'admin:' || actor_admin_user_id
where actor_admin_user_id is not null
and actor_kind = 'admin_session'
and actor_ref is distinct from 'admin:' || actor_admin_user_id;
alter table admin_audit_events
add constraint admin_audit_events_actor_identity_ck check (
actor_admin_user_id is null
or (actor_kind = 'admin_session' and actor_ref = 'admin:' || actor_admin_user_id)
);
@@ -1,28 +0,0 @@
-- Live references support FK maintenance; historical queries use *_ref indexes.
drop index model_requests_account_idx;
create index model_requests_account_idx on model_requests (provider_account_id)
where provider_account_id is not null;
drop index model_requests_client_idx;
create index model_requests_client_idx on model_requests (client_api_key_id)
where client_api_key_id is not null;
drop index ops_events_account_idx;
create index ops_events_account_idx on ops_events (provider_account_id)
where provider_account_id is not null;
drop index admin_audit_events_actor_idx;
create index admin_audit_events_actor_idx on admin_audit_events (actor_admin_user_id)
where actor_admin_user_id is not null;
-- Key usage is updated continuously; a small management list can sort on demand.
drop index client_api_keys_last_used_idx;
-- These leading keys no longer match the current runtime queries.
drop index provider_accounts_quota_access_idx;
drop index provider_accounts_refresh_due_idx;
drop index account_groups_list_idx;
drop index ops_events_component_idx;
drop index ops_events_failure_idx;
drop index admin_audit_events_actor_ref_idx;
drop index admin_audit_events_entity_idx;
@@ -1,8 +0,0 @@
-- Each row records one event; aggregation is performed by the read queries.
alter table ops_events drop constraint ops_events_values_ck;
alter table ops_events drop column occurrence_count;
alter table ops_events add constraint ops_events_values_ck check (
(status_code is null or status_code between 100 and 599)
and (retry_after_ms is null or retry_after_ms >= 0)
and (latency_ms is null or latency_ms >= 0)
);
+2 -2
View File
@@ -3,8 +3,8 @@
sqlx 在服务启动、监听请求之前按编号执行迁移,并把文件 checksum 记入 `_sqlx_migrations`。
已应用迁移的字节与记录不一致时,服务会拒绝启动。
`0001_initial.sql` 是初始基线,后续编号继续增加字段和索引;
当前完整清单以本目录 SQL 文件和 `.frozen-sha256` 为准,不要只执行 `0001`。
`0001_initial.sql` 定义数据库初始化基线,后续 schema 变更按编号新增迁移。
完整清单以本目录 SQL 文件和 `.frozen-sha256` 为准。
## 冻结规则
+4
View File
@@ -90,6 +90,10 @@ Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配
并设置 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache, no-transform`。
反向代理仍需允许这些响应头生效;首个事件到达前的等待也需要足够的读取超时。
OpenAI 上游池化 WebSocket 默认每 25 秒发送一次 Ping,发出后允许等待 30 秒;
收到 Pong 或其他入站帧即解除本次心跳截止,持续无响应则以 `pong_timeout` 关闭连接。
此策略也覆盖正在生成的请求,与等待下一条上游消息的 `stream_idle_timeout_ms` 分别计时。
若 Codex 在压缩或长时间生成时出现 `error decoding response body`,这表示
客户端读取 HTTP 响应体失败。请结合网关请求诊断中的 `upstream.read.failed`、
`downstream.body.closed` 和反向代理日志判断断开位置,不能仅凭此消息认定是 JSON 格式错误。
+3
View File
@@ -306,6 +306,9 @@ OAuth start 使用:
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度。
- OpenAI 已耗尽账号每 30 分钟主动复核一次,也会在最早未恢复窗口的 `resetAt + 2 分钟` 到期后
提前复核。后台每 30 秒检查触发条件;同一重置边界复核后仍未恢复时回到 30 分钟重试,
避免旧 reset 持续触发请求。各窗口独立确认恢复,时间到期本身不会直接解除账号耗尽。
- `POST /accounts/recover` 是管理员对本地事实的强制恢复:它清除 Redis cooldown 和已保存的额度/错误,
把账号重新启用并恢复为可调度 credential;它不验证上游账号是否已经恢复,下一次真实请求仍可重新写入
失败事实。
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BaseTablePagination } from './pagination'
import type { BaseTablePagination, PagerItem } from './pagination'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed } from 'vue'
@@ -26,6 +26,29 @@ const totalPages = computed(() => getTotalPages(props.pagination))
const currentPage = computed(() => getCurrentPage(props.pagination, totalPages.value))
const pageSizeOptions = computed(() => getPageSizeOptions(props.pagination))
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
const adjacentPagerItems = computed(() => {
const count = Math.min(3, totalPages.value)
const start = Math.max(1, Math.min(currentPage.value - 1, totalPages.value - count + 1))
return Array.from({ length: count }, (_, index) => start + index)
})
const compactPagerItems = computed<PagerItem[]>(() => {
const total = totalPages.value
const current = currentPage.value
if (total <= 5)
return Array.from({ length: total }, (_, index) => index + 1)
if (current <= 3)
return [1, 2, 3, 'ellipsis', total]
if (current >= total - 2)
return [1, 'ellipsis', total - 2, total - 1, total]
return [1, 'ellipsis', current, 'ellipsis', total]
})
const pagerVisibilityClasses = [
'flex @min-[19.5rem]/pagination:hidden',
'hidden @min-[19.5rem]/pagination:flex @min-[24.5rem]/pagination:hidden',
'hidden @min-[24.5rem]/pagination:flex @min-[29.5rem]/pagination:hidden',
'hidden @min-[29.5rem]/pagination:flex',
]
const pageSizeModel = computed({
get: () => String(props.pagination.pageSize),
@@ -61,25 +84,25 @@ function paginationPageClass(page: number) {
<template>
<footer
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
class="@container/pagination mt-2 min-h-10 min-w-0 shrink-0 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-cp-sm font-emphasis text-cp-text-secondary"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
<div class="flex items-center gap-2">
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-2 @min-[36rem]/pagination:grid-cols-[minmax(0,1fr)_auto_auto] @min-[36rem]/pagination:gap-3">
<span class="hidden min-w-0 truncate text-cp-sm font-emphasis text-cp-text-secondary @min-[36rem]/pagination:block">
共 {{ pagination.total }} 条
</span>
<BaseSelect
v-model="pageSizeModel"
aria-label="每页条数"
:options="pageSizeOptions"
:disabled="loading"
size="sm"
class="w-28"
class="w-28 shrink-0"
/>
<div class="flex items-center gap-2">
<nav
aria-label="分页"
class="flex shrink-0 items-center justify-self-end gap-2"
>
<BaseIconButton
variant="secondary"
size="sm"
@@ -90,24 +113,31 @@ function paginationPageClass(page: number) {
<ChevronLeft class="size-4" />
</BaseIconButton>
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-text-quaternary"
>
…
</span>
<button
v-else
type="button"
:class="paginationPageClass(item)"
:disabled="loading || item === currentPage"
:aria-current="item === currentPage ? 'page' : undefined"
@click="goToPage(item)"
>
{{ item }}
</button>
</template>
<div
v-for="(items, variant) in [[currentPage], adjacentPagerItems, compactPagerItems, pagerItems]"
:key="variant"
class="items-center gap-2"
:class="pagerVisibilityClasses[variant]"
>
<template v-for="(item, index) in items" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-text-quaternary"
>
…
</span>
<button
v-else
type="button"
:class="paginationPageClass(item)"
:disabled="loading || item === currentPage"
:aria-current="item === currentPage ? 'page' : undefined"
@click="goToPage(item)"
>
{{ item }}
</button>
</template>
</div>
<BaseIconButton
variant="secondary"
@@ -118,7 +148,7 @@ function paginationPageClass(page: number) {
>
<ChevronRight class="size-4" />
</BaseIconButton>
</div>
</nav>
</div>
</footer>
</template>
+1 -1
View File
@@ -1 +1 @@
version: "3.3.29"
version: "3.3.30"