mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b206ee8271 | ||
|
|
70ff923033 | ||
|
|
a2fbc073a4 | ||
|
|
9944041dc8 | ||
|
|
18a04f1213 | ||
|
|
35988d0eb7 |
@@ -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>,
|
||||
|
||||
@@ -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 ®istration.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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -3,8 +3,8 @@
|
||||
sqlx 在服务启动、监听请求之前按编号执行迁移,并把文件 checksum 记入 `_sqlx_migrations`。
|
||||
已应用迁移的字节与记录不一致时,服务会拒绝启动。
|
||||
|
||||
`0001_initial.sql` 是初始基线,后续编号继续增加字段和索引;
|
||||
当前完整清单以本目录 SQL 文件和 `.frozen-sha256` 为准,不要只执行 `0001`。
|
||||
`0001_initial.sql` 定义数据库初始化基线,后续 schema 变更按编号新增迁移。
|
||||
完整清单以本目录 SQL 文件和 `.frozen-sha256` 为准。
|
||||
|
||||
## 冻结规则
|
||||
|
||||
|
||||
@@ -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 格式错误。
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.3.29"
|
||||
version: "3.3.30"
|
||||
|
||||
Reference in New Issue
Block a user