Compare commits

...
3 Commits
Author SHA1 Message Date
ZyyandCodex 4105c1ece6 chore: bump version to 3.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:39 +08:00
ZyyandCodex 4d59687cbc docs(release): prepare v3.6.1 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:35 +08:00
ZyyandCodex 8621e24e7c fix: tolerate partial quota usage and remove retired model pricing
Keep quota capacity and remaining estimates based on recorded usage when some token or cost facts are missing. Preserve unknown values and explain potential underestimation without blocking usable forecasts.

Remove OpenAI pricing rules for shutdown models and the GPT-4-0314 alias while preserving historical costs and active regular prices. Document actual tier fallback and model-specific pricing behavior.

Validated with backend test targets, strict workspace Clippy, Rustfmt, frontend lint/typecheck/build, and mocked browser checks for partial, missing, complete, weekly/monthly, and mobile dark forecast states.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:27:49 +08:00
10 changed files with 148 additions and 96 deletions
@@ -173,22 +173,19 @@ impl AccountQuotaForecast {
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0 && !self.incomplete_tokens);
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。")
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
} else {
None
};
@@ -246,14 +246,15 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
}
#[test]
fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
fn missing_tokens_keep_estimates_from_recorded_usage() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage.missing_token_count = 1;
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.remaining_tokens, None);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
@@ -269,7 +270,7 @@ fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
}
#[test]
fn partial_or_unknown_costs_suppress_only_money_estimates() {
fn partial_costs_keep_estimates_from_known_amounts() {
for coverage in [
CostCoverage {
calculated_count: 9,
@@ -281,19 +282,77 @@ fn partial_or_unknown_costs_suppress_only_money_estimates() {
unavailable_count: 1,
..Default::default()
},
CostCoverage::default(),
] {
let mut sample = window("week", 7);
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert!(week.estimated_usd.is_none());
assert!(week.remaining_usd.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
sample.usage.request_count += 2;
sample.usage.missing_token_count = 2;
sample.usage.unavailable_cost_count = 2;
sample.usage.excluded_request_count = 1;
sample.pending_request_count = 1;
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
let mut sample = window("week", 7);
let usage = sample.local_usage.as_mut().unwrap();
usage.cost_coverage = CostCoverage {
unavailable_count: usage.request_count,
..Default::default()
};
usage.costs.clear();
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage = QuotaForecastUsage {
request_count: 2,
missing_token_count: 2,
unavailable_cost_count: 2,
..Default::default()
};
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
let mut sample = window("week", 7);
@@ -222,6 +222,8 @@ struct PricingRule {
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文
// 档位也统一按常规价计算。
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
const PRICING_RULES: &[PricingRule] = &[
// Astra:https://developers.openai.com/api/docs/models/gpt-6-astra
// 已于 2026-09-09 对照官方价目表核验。
@@ -428,14 +430,6 @@ const PRICING_RULES: &[PricingRule] = &[
},
// 各变体独立采用已公开的价格与档位,不能继承父型号的所有档位。
// 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)
@@ -450,34 +444,6 @@ const PRICING_RULES: &[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),
},
];
#[derive(Clone, Copy)]
@@ -784,7 +750,6 @@ fn normalize_model_name(model: &str) -> String {
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",
@@ -243,15 +243,11 @@ async fn exact_limit_success_body_should_be_accepted() {
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),
@@ -268,6 +264,53 @@ fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
}
}
#[test]
fn billing_should_not_price_shutdown_models_or_restore_them_through_aliases() {
// 官方关闭日期已过;同族仍在服务的基础型号不能为这些旧型号兜底。
for model in [
"gpt-4-0314",
" OpenAI/GPT-4-0314 ",
"gpt-5-codex",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2-codex",
"gpt-5-chat-latest",
"gpt-5.1-chat-latest",
"gpt-5.2-chat-latest",
"gpt-5.3-chat-latest",
] {
for tier in [None, Some("flex"), Some("fast")] {
assert!(
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), tier).is_none(),
"{model} {tier:?}"
);
}
}
}
#[test]
fn billing_should_keep_deprecated_models_before_their_shutdown_dates() {
// 截至 2026-09-13,这些型号尚未到官方关闭日期,仍按已公布单价计费。
for model in [
"gpt-3.5-turbo-instruct",
"gpt-3.5-turbo-1106",
"gpt-4-0613",
"gpt-4-turbo-2024-04-09",
"gpt-4o-2024-05-13",
"gpt-4.1-nano",
"o1",
"o1-pro",
"o3-mini",
"o4-mini",
] {
assert!(
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), None).is_some(),
"{model}"
);
}
}
#[test]
fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
for model in [
@@ -291,7 +334,7 @@ fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
("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-5.3-codex", 1, Some("flex")),
("gpt-6-astra", 1, Some("auto")),
("gpt-6-astra", 1, Some("ultrafast")),
] {
+12 -3
View File
@@ -490,7 +490,10 @@ OAuth start 使用:
`observedAt` 表示额度观测时间,与本次查询的 `generatedAt` 不同。
- `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测;
`lowSample` 表示有效进度不足 10 个百分点,或增量采样少于 2 个完整段,仅是质量提示,不承诺精度。
`incompleteCost` 仅抑制费用预测;`incompleteTokens` 仅抑制 Token 预测。未知值不按确定的零消耗外推。
`incompleteCost` / `incompleteTokens` 仅提示费用 / Token 记录存在缺口,不阻断已有数据的预测。
Token 使用已记录的正数用量,费用使用已知的有效 USD 金额;对应数据完全缺失时该项为 `null`,
两项均不可估算时才返回无可用数据的说明。明确记录的零 USD 仍可估算为零,未知费用不能当成零。
不按请求数量补齐未知消耗;漏记、失败或缺少计量可能使估算偏低,不代表完整账单或真实剩余额度。
内部采样优先采用近期分段,条件不足时才使用窗口累计估算。同一额度段内,以历史额度和截至相应
完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。
@@ -847,8 +850,14 @@ Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`
OpenAI 的 `serviceTier` 只接受上游响应生命周期事件确认的实际 `response.service_tier`;请求里的
期望档位只保留在 request summary,不能冒充响应事实。计费展示把 `priority`/`fast` 映射为 `Fast`,
`flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。Fast 优先使用模型的
priority 价格,缺少专用价格时回退到标准价格的 `2.00x`;Flex 为 `0.50x`,Default 为 `1.00x`。
`flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。各模型、档位及长上下文
区间使用明确登记的价格;缺少对应价格时不估算,不以固定倍数兜底。展示的倍率由所选档位与标准档位
费用之比计算,托管工具调用费不随 Token 档位倍增。
响应未报告实际档位时,本地费用估算回退到请求档位;该推测不写入响应 `serviceTier`,也不能确认
上游最终采用了该档位,因此本地费用不能代替官方账单。
本地计价规则只保留尚在服务的型号;已过官方关闭日期的型号不再新增本地估价。清理计价规则不删除或
重新计算已存储的历史费用;缺少当前计价规则时,历史总额仍保留,但无法再据此补充费用拆分。
## 11. 版本、更新与重启
+3 -2
View File
@@ -392,8 +392,9 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口
在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议
解析器解释,Admin 按中立的额度事实选择近期进度段。该采样不改变全站完整交付用量口径,不创建第二份
持久化额度状态。未知 Token/美元费用分别抑制对应预测;历史请求完成时间只是额度时间的近似,不承诺
严格扣额归因或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。
持久化额度状态。部分 Token/美元费用缺失仅提示精度限制,继续按已记录数值估算;对应数值完全不可用时
才不返回该项预测,不按请求数量补齐未知消耗。历史请求完成时间只是额度时间的近似,不承诺严格扣额归因
或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。
请求观测通过有界进程内队列异步投影到 PostgreSQL。队列满、Store 暂不可用或进程退出超时时,允许丢失
可恢复观测并累计指标,但不允许改变客户端响应。Usage 详情中的 attempt 因此是 best-effort,并通过
@@ -1,19 +1,10 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { ArrowRight, Info, TriangleAlert } from '@lucide/vue'
import { ArrowRight, Info } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
const source = computed(() => props.forecast.source)
const incompleteNotice = computed(() => {
if (props.forecast.incompleteTokens && props.forecast.incompleteCost)
return '用量和费用数据不完整,暂时无法预测。'
if (props.forecast.incompleteTokens)
return '用量数据不完整,暂时无法预测 Token 容量。'
if (props.forecast.incompleteCost)
return '费用数据不完整,暂时无法估算金额。'
return null
})
const metrics = computed(() => [
{
label: 'Token 容量',
@@ -89,9 +80,9 @@ const metrics = computed(() => [
{{ forecast.remainingUsdDisplay }}
</span>
</div>
<p v-if="incompleteNotice" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-warning-text">
<TriangleAlert class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
{{ incompleteNotice }}
<p v-if="forecast.incompleteTokens || forecast.incompleteCost" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-text-secondary">
<Info class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
按已记录数据估算,缺失的用量或费用可能使结果偏低。
</p>
</div>
</section>
@@ -123,7 +123,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
仅供参考
</h4>
<p class="m-0">
根据已记录用量估算,结果会随使用的模型和方式变化,并非官方承诺额度。
根据已记录用量估算,漏记或失败请求的消耗可能使结果偏低;结果会随使用的模型和方式变化,并非官方承诺额度。
</p>
<p v-if="forecast?.lowSample" class="m-0">
目前数据还较少,结果可能有较大波动。
+6 -19
View File
@@ -1,25 +1,12 @@
# v3.6.0
## 新增功能
- 创建客户端密钥时支持填写自定义 Key,便于沿用旧平台凭据;留空仍自动生成。自定义值支持不同前缀、长度和标点,沿用本平台的分组权限、限额与并发规则。
- 密钥列表、请求记录和错误列表支持按密钥名称搜索;创建和修改名称时校验重名,忽略大小写和首尾空白。
# v3.6.1
## 问题修复
- 修复 Codex 模型目录丢失原生元数据的问题,完整保留上游提示词、工具能力、上下文窗口及 `service_tiers`,使支持的客户端可按上游声明显示 `/fast` 等服务档位。目录遵守密钥的账号范围,模型别名保留目标模型的完整元数据。
- 修复同源 HTTP 管理端无法保持登录及复制失败的问题;会话 Cookie 随浏览器来源区分 HTTP 与 HTTPS,HTTPS 反向代理使用 HTTP 回源时仍保留安全属性。
- 将 OpenAI 的 `generate: false` 预热请求排除出推理用量和额度预测统计,保留审计记录与额度观测,避免预热记录干扰预测;预测面板使用实际额度观测时间。
## 体验优化
- 精简额度预测面板,聚焦容量与剩余估算,减少采样明细和重复提示。
- 精简请求诊断面板,缺少时间线时使用空状态展示,保留诊断包导出。
- 简化部署初始化命令,合并目录创建、配置复制和权限设置。
- 周/月额度预测不再因个别请求缺少 Token 或费用记录而整体不可用,继续使用已记录数据估算容量与剩余量。对应数据完全缺失时仍保留未知状态,不补算为零。
- 将“数据不完整,暂时无法预测”提示改为估算精度说明,提示漏记或失败请求的消耗可能使结果偏低。
- 清理官方已关闭的 OpenAI 模型计价规则及旧别名;仅宣布弃用、尚未到关闭日期的型号继续保留计价。
## 升级说明
- 从 v3.5.0 升级时,服务启动自动执行 `0006_custom_client_keys.sql`,保留已有密钥;无需新增配置项。历史重名密钥仍可使用,再次保存时需调整为未被占用的名称。
- 密钥相关搜索不再匹配 Key 值或可见前缀,请改用密钥名称;请求记录仍支持按 Client Key ID 查询。
- 额度预测管理接口移除了 `generatedAtDisplay`、`method` / `methodDisplay` 及部分采样诊断字段。自建管理客户端需按 [当前 API 合同](https://github.com/zyycn/codex-proxy-rs/blob/v3.6.0/docs/api.md#周月额度预测) 调整。
- Codex 客户端需重新加载模型目录,才能取得更新后的服务档位与能力信息。
- 从 v3.6.0 升级无需新增配置或数据库迁移。
- 历史费用总额保留,不重新计价;已移除计价规则的型号无法再补充历史费用拆分。现役模型的常规价格及 GPT-6 Astra 计费公式不变。
+1 -1
View File
@@ -1 +1 @@
version: "3.6.0"
version: "3.6.1"