From ed0c93fc763aa1618fe4abf1c8a3d2581f76ad71 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:50:30 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E5=B1=95=E7=A4=BA=20Codex=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BC=80=E5=A7=8B=E5=92=8C=E5=88=B0=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLI.md | 1 + docs/grafana/codexbar-codex-zh.json | 154 +++++++++++++++++++++- docs/prometheus/README.zh-CN.md | 19 ++- docs/prometheus/openapi.yaml | 40 ++++++ rust/src/cli/serve/metrics/definitions.rs | 9 ++ rust/src/cli/serve/metrics/rendering.rs | 15 +++ rust/src/cli/serve/metrics/snapshot.rs | 13 ++ rust/src/cli/serve/metrics/tests.rs | 113 +++++++++++++++- 8 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 docs/prometheus/openapi.yaml diff --git a/docs/CLI.md b/docs/CLI.md index 251f16a1d1f..ddc93f5c61a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -322,6 +322,7 @@ What the metrics export: - Quota values, for Codex only, in fixed `session`, `weekly`, `monthly` and `code_review` metric families. Used and remaining values are ratios from `0` to `1`, with no dynamic window label. - Local Codex cost estimates, when available. - Codex OAuth reset credits as `codexbar_reset_credits_available{provider="codex"}` (`0` means used up, `-1` means unavailable or a PAT), and `codexbar_reset_credits_next_expiry_timestamp_seconds{provider="codex"}` when present. Reset-credit results, good or unavailable, are cached for ten minutes. +- Codex 订阅开始和到期时间:`codexbar_subscription_start_timestamp_seconds{provider="codex"}` 对应 `startsAt`,`codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` 对应 `expiresAt`,均为 Unix 秒时间戳。只导出现有采集结果中明确提供的日期;到期时间即使已过仍保留,未提供则省略样本,不以续费或额度重置时间代替。 The metrics leave out unknown, informational, non-finite and dynamic additional-limit values. They never guess a value or use a sentinel. They don't expose account identity, display labels, source names, free-form provider errors or version strings. Alert on provider health, snapshot age and quota values together. diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index f61bab0b20f..933d4f3b259 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -973,13 +973,159 @@ "title": "距离额度重置趋势", "type": "timeseries" }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "description": "显示订阅接口明确提供的开始和到期时间。自动续费开启时通常只返回续费时间,不作为到期时间显示;接口未提供、查询失败或不支持时显示“未提供”。时间沿用仪表盘时区,默认为浏览器本地时区。", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "订阅开始时间" + }, + "properties": [ + { + "id": "unit", + "value": "dateTimeAsIso" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "0": { + "color": "orange", + "text": "未提供" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "订阅到期时间" + }, + "properties": [ + { + "id": "unit", + "value": "dateTimeAsIso" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "0": { + "color": "orange", + "text": "未提供" + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 19, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "editorMode": "code", + "expr": "(max by (instance) (codexbar_subscription_start_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 0)", + "format": "table", + "instant": true, + "legendFormat": "订阅开始时间", + "range": false, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "(max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 0)", + "format": "table", + "instant": true, + "legendFormat": "订阅到期时间", + "range": false, + "refId": "B" + } + ], + "title": "Codex 订阅信息", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "instance", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Time #A": true, + "Time #B": true, + "__name__": true, + "job": true, + "monitor": true, + "provider": true + }, + "indexByName": { + "instance": 0, + "Value #A": 1, + "Value #B": 2 + }, + "renameByName": { + "Value #A": "订阅开始时间", + "Value #B": "订阅到期时间", + "instance": "节点" + } + } + } + ], + "type": "table" + }, { "collapsed": true, "gridPos": { "h": 1, "w": 24, "x": 0, - "y": 19 + "y": 25 }, "id": 18, "panels": [ @@ -1012,7 +1158,7 @@ "h": 7, "w": 24, "x": 0, - "y": 20 + "y": 26 }, "id": 12, "options": { @@ -1073,7 +1219,7 @@ "h": 7, "w": 12, "x": 0, - "y": 27 + "y": 33 }, "id": 15, "options": { @@ -1148,7 +1294,7 @@ "h": 7, "w": 12, "x": 12, - "y": 27 + "y": 33 }, "id": 16, "options": { diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index 54aa43c7332..a77981eddca 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -1,10 +1,10 @@ # Codex Prometheus 监控示例 -用同一 Windows 用户运行 Codex CLI 和 `codexbar`,确保能读取该用户的 OAuth 凭据。将 `YOUR_RANDOM_TOKEN` 替换为长随机令牌、`YOUR_LAN_IP` 替换为节点实际局域网 IP 后启动: +用同一 Windows 用户运行 Codex CLI 和发布包中的 `codexbar-cli.exe`,确保能读取该用户的 OAuth 凭据。将 `YOUR_RANDOM_TOKEN` 替换为长随机令牌、`YOUR_LAN_IP` 替换为节点实际局域网 IP 后启动: ```cmd set CODEXBAR_DASHBOARD_TOKEN=YOUR_RANDOM_TOKEN -codexbar.exe serve --host YOUR_LAN_IP --port 8081 --metrics --allow-plain-http +codexbar-cli.exe serve --host YOUR_LAN_IP --port 8081 --metrics --allow-plain-http ``` 仅允许 Prometheus 主机访问该端口,并在可信局域网中传输令牌。`/metrics` 默认关闭,启用后与 `/usage` 共用 Bearer 认证。`--host` 应设为节点自身的局域网 IP,Prometheus target 应指向该 IP。验证: @@ -18,3 +18,18 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I 面板采用多节点中文大屏布局:顶部显示抓取、生成、时效和健康状态;中部显示剩余额度、重置卡数量与有效期、Codex 采集状态,以及“距离额度重置趋势”;底部的成本和采集详情默认折叠。倒计时按每个历史采样时刻计算 `max(重置时间 - 采样时间, 0) / 86400`,可观察剩余天数逐步下降。节点筛选支持单台或全部,额度窗口默认 weekly。上游额度指标按窗口命名(例如 `codexbar_quota_weekly_remaining_ratio`),面板自动将 0 到 1 的比例换算为百分比。 `codexbar_reset_credits_available{provider="codex"}` 中,`0` 为已用完,`-1` 为 PAT 模式或接口不可用。`codexbar_reset_credits_next_expiry_timestamp_seconds` 只在有可用卡且存在未来有效期时输出,不暴露卡 ID。重置卡接口查询成功或失败均缓存 10 分钟;异常的周额度重置确认会获取独立观察。配额指标以 0 到 1 的比例表示,Grafana 面板会按百分比显示。成本来自本地日志估算,不是订阅账单。 + +## 订阅开始和到期时间 + +“Codex 订阅信息”表按节点展示以下字段,独立于额度窗口筛选: + +| 订阅字段 | Prometheus 指标 | 含义 | +| --- | --- | --- | +| `startsAt` | `codexbar_subscription_start_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅开始时间 | +| `expiresAt` | `codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅到期时间;已过期的日期仍保留 | + +指标值是 Unix 秒时间戳,Grafana 查询乘以 1000 后显示日期,时间沿用仪表盘时区(默认浏览器本地时区)。这些字段来自现有采集结果,抓取 `/metrics` 不会另外请求订阅接口。 + +需开启设置中的“OpenAI 网页附加功能”(默认开启),并且当前账号能成功访问订阅接口。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充到期时间;开始时间也只有接口明确提供时才有值。缺失、查询失败或不支持时,不输出相应时间戳样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期或续费时间推算订阅到期时间。 + +接口说明见 [OpenAPI 文档](openapi.yaml)。 diff --git a/docs/prometheus/openapi.yaml b/docs/prometheus/openapi.yaml new file mode 100644 index 00000000000..ca6f7a212a4 --- /dev/null +++ b/docs/prometheus/openapi.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: CodexBar Prometheus 指标接口 + version: 1.0.0 + description: 指标从已有采集结果生成;默认关闭,使用 serve --metrics 启用。 +paths: + /metrics: + get: + summary: 获取 Prometheus 文本指标 + description: >- + 沿用服务的 Host 白名单和 Bearer 认证。订阅开始、到期时间为 Unix 秒时间戳; + 未取得日期时省略相应样本。自动续费日期不作为到期时间。 + 服务尚无采集结果时返回 codexbar_up 0,不因抓取同步请求提供方。 + security: + - bearerAuth: [] + responses: + '200': + description: Prometheus 0.0.4 文本格式 + content: + text/plain: + schema: + type: string + example: | + # TYPE codexbar_subscription_start_timestamp_seconds gauge + codexbar_subscription_start_timestamp_seconds{provider="codex"} 1790812800 + # TYPE codexbar_subscription_expiry_timestamp_seconds gauge + codexbar_subscription_expiry_timestamp_seconds{provider="codex"} 1793491200 + '401': + description: 未提供有效的 Bearer 令牌 + '403': + description: 请求的 Host 不在允许范围内 + '404': + description: 未启用指标接口 + '500': + description: 无法生成指标;响应不包含内部错误详情 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer diff --git a/rust/src/cli/serve/metrics/definitions.rs b/rust/src/cli/serve/metrics/definitions.rs index 4e37b421f03..e5c98d73706 100644 --- a/rust/src/cli/serve/metrics/definitions.rs +++ b/rust/src/cli/serve/metrics/definitions.rs @@ -101,6 +101,15 @@ pub(in crate::cli::serve::metrics) const METRIC_DEFINITIONS: &[(&str, &str)] = & "codexbar_reset_credits_next_expiry_timestamp_seconds", "Unix timestamp of the earliest expiry among available Codex reset credits.", ), + // 订阅日期与重置卡有效期、额度重置时间分别建模,缺失时不输出样本。 + ( + "codexbar_subscription_start_timestamp_seconds", + "Codex 订阅开始时间的 Unix 秒时间戳,仅在接口明确提供时输出。", + ), + ( + "codexbar_subscription_expiry_timestamp_seconds", + "Codex 订阅到期时间的 Unix 秒时间戳,包含已过期的日期。", + ), ( "codexbar_cost_last_30_days_usd", "Available local Codex cost over the last 30 days in US dollars.", diff --git a/rust/src/cli/serve/metrics/rendering.rs b/rust/src/cli/serve/metrics/rendering.rs index 806a98e1a0c..d045f360b4c 100644 --- a/rust/src/cli/serve/metrics/rendering.rs +++ b/rust/src/cli/serve/metrics/rendering.rs @@ -118,6 +118,21 @@ pub(in crate::cli::serve::metrics) fn render_at( expiry.timestamp(), )?; } + // 已过期的订阅日期仍需输出,供监控区分“过期”和“未提供”。 + if let Some(start) = provider.subscription_start { + writer.sample( + "codexbar_subscription_start_timestamp_seconds", + &provider_labels, + start.timestamp(), + )?; + } + if let Some(expiry) = provider.subscription_expiry { + writer.sample( + "codexbar_subscription_expiry_timestamp_seconds", + &provider_labels, + expiry.timestamp(), + )?; + } if let Some(value) = provider.cost_today_usd { writer.sample_f64("codexbar_cost_today_usd", &provider_labels, value)?; } diff --git a/rust/src/cli/serve/metrics/snapshot.rs b/rust/src/cli/serve/metrics/snapshot.rs index 6333fd91dce..f2e2967d7a8 100644 --- a/rust/src/cli/serve/metrics/snapshot.rs +++ b/rust/src/cli/serve/metrics/snapshot.rs @@ -24,6 +24,8 @@ pub(in crate::cli::serve::metrics) struct ProviderMetrics { pub(in crate::cli::serve::metrics) codex_quota: Option, pub(in crate::cli::serve::metrics) reset_credits_available: Option, pub(in crate::cli::serve::metrics) reset_credits_next_expiry: Option>, + pub(in crate::cli::serve::metrics) subscription_start: Option>, + pub(in crate::cli::serve::metrics) subscription_expiry: Option>, pub(in crate::cli::serve::metrics) cost_today_usd: Option, pub(in crate::cli::serve::metrics) cost_last_30_days_usd: Option, } @@ -98,6 +100,15 @@ impl MetricsSnapshot { .filter(|credits| credits.available_count > 0) .and_then(|credits| credits.next_expires_at) .filter(|expiry| *expiry > input.generated_at), + // 只使用 Codex 明确提供的订阅日期,不以续费或额度重置时间代替。 + subscription_start: (id == ProviderId::Codex) + .then_some(result.usage.subscription.as_ref()) + .flatten() + .and_then(|subscription| subscription.starts_at), + subscription_expiry: (id == ProviderId::Codex) + .then_some(result.usage.subscription.as_ref()) + .flatten() + .and_then(|subscription| subscription.expires_at), cost_today_usd, cost_last_30_days_usd, }, @@ -108,6 +119,8 @@ impl MetricsSnapshot { codex_quota: None, reset_credits_available: None, reset_credits_next_expiry: None, + subscription_start: None, + subscription_expiry: None, cost_today_usd, cost_last_30_days_usd, }, diff --git a/rust/src/cli/serve/metrics/tests.rs b/rust/src/cli/serve/metrics/tests.rs index 43a836b12da..ff2aff6204e 100644 --- a/rust/src/cli/serve/metrics/tests.rs +++ b/rust/src/cli/serve/metrics/tests.rs @@ -12,7 +12,7 @@ use crate::cli::serve::dashboard::coordinator::{SnapshotArtifacts, SnapshotArtif use crate::cli::serve::dashboard::snapshot::{DashboardIdentity, SnapshotInput, build_snapshot}; use crate::core::{ NamedRateWindow, ProviderFetchResult, ProviderInventoryItem, RateWindow, - ResetCreditsObservation, UsageSnapshot, + ResetCreditsObservation, SubscriptionMetadata, UsageSnapshot, }; use crate::providers::codex::CodexApi; @@ -204,6 +204,117 @@ fn renders_enabled_provider_health_and_codex_metrics_without_private_text() { } } +#[test] +fn subscription_dates_export_explicit_start_and_expiry_even_after_expiration() { + // 已到期时间仍需保留,监控才能识别过期,而不能把它误判为缺失。 + let codex_usage = usage(RateWindow::new(10.0)).with_subscription(Some( + SubscriptionMetadata::new(Some(at(1)), Some(at(3)), None), + )); + let snapshot = metrics_snapshot( + vec![provider( + "codex", + Ok(ProviderFetchResult::new(codex_usage, "oauth")), + )], + HashMap::new(), + ); + let body = render_at(&snapshot, at(5)).unwrap(); + for (name, date) in [ + ("codexbar_subscription_start_timestamp_seconds", at(1)), + ("codexbar_subscription_expiry_timestamp_seconds", at(3)), + ] { + assert_eq!( + samples(&body, name).collect::>(), + vec![format!("{name}{{provider=\"codex\"}} {}", date.timestamp())] + ); + assert!(body.contains(&format!("# TYPE {name} gauge\n"))); + } +} + +#[test] +fn subscription_dates_do_not_infer_expiry_from_renewal_or_quota_reset() { + // 自动续费日期和额度重置日期都不能代替订阅到期时间。 + for subscription in [ + None, + Some(SubscriptionMetadata::new(None, None, Some(at(4)))), + ] { + let codex_usage = usage(RateWindow::with_details(10.0, Some(300), Some(at(6)), None)) + .with_subscription(subscription); + let body = render_at( + &metrics_snapshot( + vec![provider( + "codex", + Ok(ProviderFetchResult::new(codex_usage, "oauth")), + )], + HashMap::new(), + ), + at(2), + ) + .unwrap(); + assert_eq!( + samples(&body, "codexbar_subscription_start_timestamp_seconds").count(), + 0 + ); + assert_eq!( + samples(&body, "codexbar_subscription_expiry_timestamp_seconds").count(), + 0 + ); + } +} + +#[test] +fn subscription_dates_keep_independent_fields_and_codex_provider_scope() { + // 各字段独立导出;其他提供方即使含有订阅日期,也不进入 Codex 指标。 + for (start, expiry) in [(Some(at(1)), None), (None, Some(at(3)))] { + let subscribed = usage(RateWindow::new(10.0)) + .with_subscription(Some(SubscriptionMetadata::new(start, expiry, None))); + let snapshot = metrics_snapshot( + vec![ + provider( + "codex", + Ok(ProviderFetchResult::new(subscribed.clone(), "oauth")), + ), + provider("claude", Ok(ProviderFetchResult::new(subscribed, "oauth"))), + ], + HashMap::new(), + ); + let body = render_at(&snapshot, at(2)).unwrap(); + assert_eq!( + samples(&body, "codexbar_subscription_start_timestamp_seconds").count(), + usize::from(start.is_some()) + ); + assert_eq!( + samples(&body, "codexbar_subscription_expiry_timestamp_seconds").count(), + usize::from(expiry.is_some()) + ); + assert!( + !body + .lines() + .any(|line| line.starts_with("codexbar_subscription_") && line.contains("claude")) + ); + } +} + +#[test] +fn subscription_dates_are_omitted_when_provider_fetch_fails() { + let body = render_at( + &metrics_snapshot( + vec![provider("codex", Err("private error".into()))], + HashMap::new(), + ), + at(2), + ) + .unwrap(); + assert_eq!( + samples(&body, "codexbar_subscription_start_timestamp_seconds").count(), + 0 + ); + assert_eq!( + samples(&body, "codexbar_subscription_expiry_timestamp_seconds").count(), + 0 + ); + assert!(!body.contains("private error")); +} + #[test] fn reset_credits_export_count_and_only_future_expiry() { let result = ProviderFetchResult::new(usage(RateWindow::new(10.0)), "oauth") From 2a8956b329603117a1e026c0c47f80f5d3688666 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:50:44 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20Grafana=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=AD=9B=E9=80=89=E7=9A=84=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/grafana/codexbar-codex-zh.json | 40 +++++++------- docs/grafana/test_queries.py | 83 +++++++++++++++++++++++++++++ docs/prometheus/README.zh-CN.md | 6 +++ 3 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 docs/grafana/test_queries.py diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index 933d4f3b259..f8b71a4ffaa 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -89,7 +89,7 @@ "targets": [ { "editorMode": "code", - "expr": "min(up{job=~\"${job:regex}\",instance=~\"${instance:regex}\"})", + "expr": "min(up{job=~\"${job}\",instance=~\"${instance}\"})", "instant": true, "legendFormat": "抓取", "range": false, @@ -166,7 +166,7 @@ "targets": [ { "editorMode": "code", - "expr": "min(codexbar_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\"})", + "expr": "min(codexbar_up{job=~\"${job}\",instance=~\"${instance}\"})", "instant": true, "legendFormat": "指标生成", "range": false, @@ -233,7 +233,7 @@ "targets": [ { "editorMode": "code", - "expr": "max(codexbar_snapshot_age_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\"})", + "expr": "max(codexbar_snapshot_age_seconds{job=~\"${job}\",instance=~\"${instance}\"})", "instant": true, "legendFormat": "指标数据年龄", "range": false, @@ -310,7 +310,7 @@ "targets": [ { "editorMode": "code", - "expr": "max(codexbar_snapshot_stale{job=~\"${job:regex}\",instance=~\"${instance:regex}\"})", + "expr": "max(codexbar_snapshot_stale{job=~\"${job}\",instance=~\"${instance}\"})", "instant": true, "legendFormat": "时效状态", "range": false, @@ -380,7 +380,7 @@ "targets": [ { "editorMode": "code", - "expr": "100 * sum(codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} * on(job, instance) group_left up{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}) / clamp_min(count(up{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}), 1)", + "expr": "100 * sum(codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"} * on(job, instance) group_left up{job=~\"${job}\",instance=~\"${instance}\"}) / clamp_min(count(up{job=~\"${job}\",instance=~\"${instance}\"}), 1)", "instant": true, "legendFormat": "健康率", "range": false, @@ -439,7 +439,7 @@ "targets": [ { "editorMode": "code", - "expr": "max(codexbar_refresh_interval_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\"})", + "expr": "max(codexbar_refresh_interval_seconds{job=~\"${job}\",instance=~\"${instance}\"})", "instant": true, "legendFormat": "刷新间隔", "range": false, @@ -512,7 +512,7 @@ "targets": [ { "editorMode": "code", - "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} * 100", + "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"} * 100", "instant": true, "legendFormat": "{{instance}}", "range": false, @@ -603,7 +603,7 @@ "targets": [ { "editorMode": "code", - "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} * 100", + "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"} * 100", "hide": false, "legendFormat": "{{instance}} 剩余", "range": true, @@ -726,7 +726,7 @@ "targets": [ { "editorMode": "code", - "expr": "max by (instance) (codexbar_reset_credits_available{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"})", + "expr": "max by (instance) (codexbar_reset_credits_available{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})", "format": "table", "instant": true, "legendFormat": "可用张数", @@ -735,7 +735,7 @@ }, { "editorMode": "code", - "expr": "(max by (instance) (codexbar_reset_credits_next_expiry_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_reset_credits_available{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 0)", + "expr": "(max by (instance) (codexbar_reset_credits_next_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_reset_credits_available{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", "format": "table", "instant": true, "legendFormat": "最近到期", @@ -850,7 +850,7 @@ "targets": [ { "editorMode": "code", - "expr": "codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "expr": "codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}", "format": "table", "instant": true, "legendFormat": "{{instance}} / {{provider}}", @@ -964,7 +964,7 @@ "targets": [ { "editorMode": "code", - "expr": "clamp_min(codexbar_quota_${window:raw}_reset_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} - time(), 0) / 86400", + "expr": "clamp_min(codexbar_quota_${window:raw}_reset_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"} - time(), 0) / 86400", "legendFormat": "{{instance}}", "range": true, "refId": "A" @@ -1066,7 +1066,7 @@ "targets": [ { "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_start_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 0)", + "expr": "(max by (instance) (codexbar_subscription_start_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", "format": "table", "instant": true, "legendFormat": "订阅开始时间", @@ -1075,7 +1075,7 @@ }, { "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}) * 0)", + "expr": "(max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", "format": "table", "instant": true, "legendFormat": "订阅到期时间", @@ -1179,7 +1179,7 @@ "targets": [ { "editorMode": "code", - "expr": "codexbar_cost_today_usd{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "expr": "codexbar_cost_today_usd{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}", "instant": true, "legendFormat": "{{instance}} 今日", "range": false, @@ -1187,7 +1187,7 @@ }, { "editorMode": "code", - "expr": "codexbar_cost_last_30_days_usd{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "expr": "codexbar_cost_last_30_days_usd{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}", "instant": true, "legendFormat": "{{instance}} 近 30 天", "range": false, @@ -1237,7 +1237,7 @@ "targets": [ { "editorMode": "code", - "expr": "label_replace(codexbar_snapshot_schema_version{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}, \"metric\", \"数据结构版本\", \"\", \"\") or label_replace(codexbar_snapshot_generated_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}, \"metric\", \"指标生成时间\", \"\", \"\") or label_replace(codexbar_snapshot_stale_after_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}, \"metric\", \"指标过期阈值\", \"\", \"\")", + "expr": "label_replace(codexbar_snapshot_schema_version{job=~\"${job}\",instance=~\"${instance}\"}, \"metric\", \"数据结构版本\", \"\", \"\") or label_replace(codexbar_snapshot_generated_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\"}, \"metric\", \"指标生成时间\", \"\", \"\") or label_replace(codexbar_snapshot_stale_after_seconds{job=~\"${job}\",instance=~\"${instance}\"}, \"metric\", \"指标过期阈值\", \"\", \"\")", "instant": true, "legendFormat": "{{instance}} / {{metric}}", "range": false, @@ -1312,7 +1312,7 @@ "targets": [ { "editorMode": "code", - "expr": "label_replace(codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}, \"metric\", \"采集状态\", \"\", \"\") or label_replace(codexbar_provider_data_age_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}, \"metric\", \"数据年龄\", \"\", \"\") or label_replace(codexbar_provider_updated_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}, \"metric\", \"数据更新时间\", \"\", \"\")", + "expr": "label_replace(codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}, \"metric\", \"采集状态\", \"\", \"\") or label_replace(codexbar_provider_data_age_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}, \"metric\", \"数据年龄\", \"\", \"\") or label_replace(codexbar_provider_updated_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}, \"metric\", \"数据更新时间\", \"\", \"\")", "instant": true, "legendFormat": "{{instance}} / {{provider}} / {{metric}}", "range": false, @@ -1415,7 +1415,7 @@ "type": "prometheus", "uid": "${datasource}" }, - "definition": "label_values(codexbar_provider_up{provider=\"codex\",job=~\"${job:regex}\"},instance)", + "definition": "label_values(codexbar_provider_up{provider=\"codex\",job=~\"${job}\"},instance)", "hide": 0, "includeAll": true, "label": "节点", @@ -1423,7 +1423,7 @@ "name": "instance", "options": [], "query": { - "query": "label_values(codexbar_provider_up{provider=\"codex\",job=~\"${job:regex}\"},instance)", + "query": "label_values(codexbar_provider_up{provider=\"codex\",job=~\"${job}\"},instance)", "refId": "StandardVariableQuery" }, "refresh": 1, diff --git a/docs/grafana/test_queries.py b/docs/grafana/test_queries.py new file mode 100644 index 00000000000..dc4868bfc2a --- /dev/null +++ b/docs/grafana/test_queries.py @@ -0,0 +1,83 @@ +"""复现 Grafana 多选变量的转义行为,校验最终 PromQL 字符串而非预先补转义。""" + +import json +from pathlib import Path +import re +import unittest + + +def expand_variable(values, formatter): + # 通用 :regex 只处理正则层;Prometheus 默认格式还处理 PromQL 字符串层。 + patterns = [re.sub(r"[\\$^*{}\[\]+?.()|]", lambda match: "\\" + match[0], value) + for value in values] + pattern = patterns[0] if len(patterns) == 1 else "(" + "|".join(patterns) + ")" + if formatter == "regex": + return pattern + return pattern.replace("\\", "\\\\").replace('"', '\\"') + + +def expand_query(query, variables, window): + query = query.replace("${window:raw}", window) + return re.sub( + r"\$\{(job|instance)(?::(regex))?\}", + lambda match: expand_variable(variables[match[1]], match[2]), + query, + ) + + +def queries(document): + if isinstance(document, dict): + for key, value in document.items(): + if key in ("expr", "definition", "query") and isinstance(value, str) and "${" in value: + yield value + else: + yield from queries(value) + elif isinstance(document, list): + for value in document: + yield from queries(value) + + +class DashboardQueriesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dashboard = json.loads(Path(__file__).with_name("codexbar-codex-zh.json").read_text(encoding="utf-8")) + + def test_query_literals_for_single_multiple_and_all_nodes(self): + nodes = ["192.168.13.111:8081", "192.168.13.184:8081", "192.168.13.241:8081"] + for window in ("weekly", "session", "monthly", "code_review"): + for jobs in (["codex-bar"], ["codex.bar", "codex-other"]): + for selected in (nodes[:1], nodes[:2], nodes): + for query in queries(self.dashboard): + with self.subTest(window=window, jobs=jobs, nodes=selected, query=query): + expanded = expand_query(query, {"job": jobs, "instance": selected}, window) + self.assertNotIn("${", expanded) + # 此处 IP 与正则的转义在 JSON 和 PromQL 双引号字符串中具有相同语法。 + for operator, literal in re.findall(r'(=~|!~|!=|=)\s*("(?:\\.|[^"\\])*")', expanded): + pattern = json.loads(literal) + if operator in ("=~", "!~"): + re.compile(pattern) + + def test_default_interpolation_matches_literal_ips(self): + expanded = expand_query( + self.dashboard["panels"][0]["targets"][0]["expr"], + {"job": ["codex-bar"], "instance": ["192.168.13.111:8081", "192.168.13.241:8081"]}, + "weekly", + ) + literal = re.search(r'instance=~("(?:\\.|[^"\\])*")', expanded)[1] + pattern = json.loads(literal) + self.assertIsNotNone(re.fullmatch(pattern, "192.168.13.111:8081")) + self.assertIsNotNone(re.fullmatch(pattern, "192.168.13.241:8081")) + self.assertIsNone(re.fullmatch(pattern, "192a168b13c111:8081")) + + def test_explicit_regex_formatter_reproduces_reported_failure(self): + expanded = expand_query( + 'up{instance=~"${instance:regex}"}', + {"instance": ["192.168.13.111:8081"]}, + "weekly", + ) + with self.assertRaises(json.JSONDecodeError): + json.loads(expanded[len('up{instance=~'):-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index a77981eddca..1f9c9bf0da2 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -33,3 +33,9 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I 需开启设置中的“OpenAI 网页附加功能”(默认开启),并且当前账号能成功访问订阅接口。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充到期时间;开始时间也只有接口明确提供时才有值。缺失、查询失败或不支持时,不输出相应时间戳样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期或续费时间推算订阅到期时间。 接口说明见 [OpenAPI 文档](openapi.yaml)。 + +## 面板查询校验 + +节点和采集任务筛选使用 Prometheus 数据源默认插值 `${instance}`、`${job}`,由数据源同时处理正则和 PromQL 字符串的转义。不要在引号内改为通用 `${instance:regex}`、`${job:regex}`:Grafana 10.4.3 会为 IP 中的点生成单个反斜杠,导致 `unknown escape sequence`,单节点和“全部”筛选都可能失败。 + +在仓库根目录运行 `python docs/grafana/test_queries.py`,验证单节点、多节点、全部节点和含点任务名的展开结果。此用例会先复现通用 `:regex` 格式的错误,再验证面板配置;发布前还应在实际 Grafana 中验证筛选和数据展示。 From e3e0f86eaf17e45647a4595fc5fef6964536b121 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:10:03 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E8=AE=A2=E9=98=85=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E5=AF=BC=E5=87=BA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=AD=E7=9A=84=E5=91=A8=E6=9C=9F=E6=97=A5?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLI.md | 1 + docs/grafana/codexbar-codex-zh.json | 127 +++++++++++++++++- docs/grafana/test_queries.py | 13 ++ docs/prometheus/README.zh-CN.md | 11 +- docs/prometheus/openapi.yaml | 3 + rust/src/cli/serve/metrics/definitions.rs | 14 +- rust/src/cli/serve/metrics/rendering.rs | 22 +++ rust/src/cli/serve/metrics/snapshot.rs | 34 ++++- rust/src/cli/serve/metrics/tests.rs | 90 +++++++++++++ rust/src/core/usage_snapshot.rs | 14 ++ rust/src/providers/codex/api.rs | 6 + rust/src/providers/codex/api/credentials.rs | 57 +++++++- .../codex/api/subscription_period_tests.rs | 105 +++++++++++++++ rust/src/providers/codex/api/tests.rs | 2 + rust/src/providers/codex/subscription.rs | 60 ++++++--- 15 files changed, 535 insertions(+), 24 deletions(-) create mode 100644 rust/src/providers/codex/api/subscription_period_tests.rs diff --git a/docs/CLI.md b/docs/CLI.md index ddc93f5c61a..354e3f47293 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -323,6 +323,7 @@ What the metrics export: - Local Codex cost estimates, when available. - Codex OAuth reset credits as `codexbar_reset_credits_available{provider="codex"}` (`0` means used up, `-1` means unavailable or a PAT), and `codexbar_reset_credits_next_expiry_timestamp_seconds{provider="codex"}` when present. Reset-credit results, good or unavailable, are cached for ten minutes. - Codex 订阅开始和到期时间:`codexbar_subscription_start_timestamp_seconds{provider="codex"}` 对应 `startsAt`,`codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` 对应 `expiresAt`,均为 Unix 秒时间戳。只导出现有采集结果中明确提供的日期;到期时间即使已过仍保留,未提供则省略样本,不以续费或额度重置时间代替。 +- 订阅接口不可用时,同账号 `id_token` 中明确记录的周期开始可作为开始时间的备用来源;周期结束独立输出为 `codexbar_subscription_period_end_timestamp_seconds`,不填入 `expiresAt`。`codexbar_subscription_dates_from_id_token` 的 `1` 表示登录记录、`0` 表示订阅接口;`codexbar_subscription_dates_recorded_timestamp_seconds` 标记登录记录时间。登录记录可能滞后,不是当前续费或取消状态;仅在 OAuth 用量成功、身份匹配、网页附加功能开启且使用标准 ChatGPT 后端时使用,不额外请求上游、不改动凭据。 The metrics leave out unknown, informational, non-finite and dynamic additional-limit values. They never guess a value or use a sentinel. They don't expose account identity, display labels, source names, free-form provider errors or version strings. Alert on provider health, snapshot age and quota values together. diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index f8b71a4ffaa..987057e947a 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -978,7 +978,7 @@ "type": "prometheus", "uid": "${datasource}" }, - "description": "显示订阅接口明确提供的开始和到期时间。自动续费开启时通常只返回续费时间,不作为到期时间显示;接口未提供、查询失败或不支持时显示“未提供”。时间沿用仪表盘时区,默认为浏览器本地时区。", + "description": "订阅接口成功时显示其明确提供的开始和到期时间。接口不可用时,可显示同账号登录凭据中明确记录的订阅开始和周期结束时间,日期来源标为“登录记录”;登录记录可能滞后,周期结束不表示已取消续费,也不保证届时停用。日期记录时间为该登录声明的签发时间,不是本次采集时间。缺失时显示“未提供”;时间沿用仪表盘时区,默认浏览器本地时区。", "fieldConfig": { "defaults": { "custom": { @@ -1041,6 +1041,87 @@ ] } ] + }, + { + "matcher": { + "id": "byName", + "options": "周期结束时间" + }, + "properties": [ + { + "id": "unit", + "value": "dateTimeAsIso" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "0": { + "color": "orange", + "text": "未提供" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "日期记录时间" + }, + "properties": [ + { + "id": "unit", + "value": "dateTimeAsIso" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "0": { + "color": "orange", + "text": "未提供" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "日期来源" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "0": { + "text": "订阅接口" + }, + "1": { + "text": "登录记录", + "color": "orange" + }, + "-1": { + "text": "未提供", + "color": "orange" + } + } + } + ] + } + ] } ] }, @@ -1081,6 +1162,33 @@ "legendFormat": "订阅到期时间", "range": false, "refId": "B" + }, + { + "editorMode": "code", + "expr": "(max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", + "format": "table", + "instant": true, + "legendFormat": "周期结束时间", + "range": false, + "refId": "C" + }, + { + "editorMode": "code", + "expr": "(max by (instance) (codexbar_subscription_dates_from_id_token{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0 - 1)", + "format": "table", + "instant": true, + "legendFormat": "日期来源", + "range": false, + "refId": "D" + }, + { + "editorMode": "code", + "expr": "(max by (instance) (codexbar_subscription_dates_recorded_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", + "format": "table", + "instant": true, + "legendFormat": "日期记录时间", + "range": false, + "refId": "E" } ], "title": "Codex 订阅信息", @@ -1102,17 +1210,26 @@ "__name__": true, "job": true, "monitor": true, - "provider": true + "provider": true, + "Time #C": true, + "Time #D": true, + "Time #E": true }, "indexByName": { "instance": 0, "Value #A": 1, - "Value #B": 2 + "Value #C": 2, + "Value #B": 3, + "Value #D": 4, + "Value #E": 5 }, "renameByName": { "Value #A": "订阅开始时间", "Value #B": "订阅到期时间", - "instance": "节点" + "instance": "节点", + "Value #C": "周期结束时间", + "Value #D": "日期来源", + "Value #E": "日期记录时间" } } } @@ -1482,7 +1599,7 @@ "timezone": "browser", "title": "CodexBar · Codex 监控", "uid": null, - "version": 1, + "version": 2, "weekStart": "", "description": "Codex 多节点中文监控:剩余额度、重置倒计时、重置卡与采集状态。适配上游固定窗口 ratio 指标。" } diff --git a/docs/grafana/test_queries.py b/docs/grafana/test_queries.py index dc4868bfc2a..ca025508d4e 100644 --- a/docs/grafana/test_queries.py +++ b/docs/grafana/test_queries.py @@ -78,6 +78,19 @@ def test_explicit_regex_formatter_reproduces_reported_failure(self): with self.assertRaises(json.JSONDecodeError): json.loads(expanded[len('up{instance=~'):-1]) + def test_subscription_period_and_expiry_keep_distinct_columns_and_source(self): + # 登录记录可能滞后,周期结束和确认的到期日期必须分列并标记来源。 + panel = next(panel for panel in self.dashboard["panels"] if panel.get("title") == "Codex 订阅信息") + expressions = "\n".join(target["expr"] for target in panel["targets"]) + for name in ("codexbar_subscription_period_end_timestamp_seconds", + "codexbar_subscription_expiry_timestamp_seconds", + "codexbar_subscription_dates_from_id_token", + "codexbar_subscription_dates_recorded_timestamp_seconds"): + self.assertIn(name, expressions) + names = panel["transformations"][1]["options"]["renameByName"].values() + for column in ("订阅开始时间", "订阅到期时间", "周期结束时间", "日期来源", "日期记录时间"): + self.assertIn(column, names) + if __name__ == "__main__": unittest.main() diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index 1f9c9bf0da2..af89b6568a7 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -27,10 +27,19 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I | --- | --- | --- | | `startsAt` | `codexbar_subscription_start_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅开始时间 | | `expiresAt` | `codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅到期时间;已过期的日期仍保留 | +| 登录记录中的周期终点 | `codexbar_subscription_period_end_timestamp_seconds{provider="codex"}` | `id_token` 明确提供的 `chatgpt_subscription_active_until`;不等于确认的取消续费到期时间 | +| 日期来源 | `codexbar_subscription_dates_from_id_token{provider="codex"}` | `1` 为登录记录,`0` 为订阅接口;无日期时省略 | +| 日期记录时间 | `codexbar_subscription_dates_recorded_timestamp_seconds{provider="codex"}` | 登录声明的签发时间 `iat`;不是监控采集时间 | 指标值是 Unix 秒时间戳,Grafana 查询乘以 1000 后显示日期,时间沿用仪表盘时区(默认浏览器本地时区)。这些字段来自现有采集结果,抓取 `/metrics` 不会另外请求订阅接口。 -需开启设置中的“OpenAI 网页附加功能”(默认开启),并且当前账号能成功访问订阅接口。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充到期时间;开始时间也只有接口明确提供时才有值。缺失、查询失败或不支持时,不输出相应时间戳样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期或续费时间推算订阅到期时间。 +需开启设置中的“OpenAI 网页附加功能”(默认开启)。订阅接口成功时始终优先,包括明确返回空订阅的情况。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充到期时间。 + +当订阅接口不可用时,OAuth 用量采集成功后可使用同一 `auth.json` 的 `id_token` 中明确记录的周期:`chatgpt_subscription_active_start` 作为开始时间,`chatgpt_subscription_active_until` 作为单独的“周期结束时间”。只有令牌声明的账号 ID 与当前凭据账号 ID 一致才接受;身份缺失、不匹配、日期格式错误或周期倒置时均忽略。PAT、API Key 和自定义后端不使用此降级来源。 + +登录记录只反映令牌签发时的订阅周期,可能滞后,不能据此确认自动续费、取消订阅或当前订阅状态。面板显示“登录记录”和可用的日期记录时间;旧周期结束日期仍保留,不能仅凭它告警“账号已到期”。不读取或更改刷新令牌、不触发登录,不增加网络请求。新的周期信息在 Codex 自己更新凭据后随下一次正常采集读取。 + +登录周期仅通过 `/metrics` 导出,不写入持久化或通用 `subscription.expiresAt` 字段,也不进入桌面订阅到期提示。未取得日期时省略对应样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期、JWT `exp` 或续费时间推算订阅到期时间。 接口说明见 [OpenAPI 文档](openapi.yaml)。 diff --git a/docs/prometheus/openapi.yaml b/docs/prometheus/openapi.yaml index ca6f7a212a4..885c37675a2 100644 --- a/docs/prometheus/openapi.yaml +++ b/docs/prometheus/openapi.yaml @@ -10,6 +10,9 @@ paths: description: >- 沿用服务的 Host 白名单和 Bearer 认证。订阅开始、到期时间为 Unix 秒时间戳; 未取得日期时省略相应样本。自动续费日期不作为到期时间。 + 订阅接口不可用时可导出同账号登录凭据明确记录的周期;周期结束独立于到期时间。 + dates_from_id_token 为 1 表示登录记录、0 表示订阅接口;登录记录可能滞后, + dates_recorded_timestamp_seconds 标记其签发时间,而非本次监控采集时间。 服务尚无采集结果时返回 codexbar_up 0,不因抓取同步请求提供方。 security: - bearerAuth: [] diff --git a/rust/src/cli/serve/metrics/definitions.rs b/rust/src/cli/serve/metrics/definitions.rs index e5c98d73706..a20acf8b3b2 100644 --- a/rust/src/cli/serve/metrics/definitions.rs +++ b/rust/src/cli/serve/metrics/definitions.rs @@ -104,12 +104,24 @@ pub(in crate::cli::serve::metrics) const METRIC_DEFINITIONS: &[(&str, &str)] = & // 订阅日期与重置卡有效期、额度重置时间分别建模,缺失时不输出样本。 ( "codexbar_subscription_start_timestamp_seconds", - "Codex 订阅开始时间的 Unix 秒时间戳,仅在接口明确提供时输出。", + "Codex 明确报告的订阅开始时间;来源由 dates_from_id_token 指标区分。", ), ( "codexbar_subscription_expiry_timestamp_seconds", "Codex 订阅到期时间的 Unix 秒时间戳,包含已过期的日期。", ), + ( + "codexbar_subscription_period_end_timestamp_seconds", + "Codex 登录凭据记录的订阅周期结束时间,不代表已确认取消后的到期时间。", + ), + ( + "codexbar_subscription_dates_recorded_timestamp_seconds", + "Codex 订阅周期声明记录时间(id_token iat),不是本次监控采集时间。", + ), + ( + "codexbar_subscription_dates_from_id_token", + "Codex 订阅日期来源:1 为登录凭据记录,0 为订阅接口;无日期时省略。", + ), ( "codexbar_cost_last_30_days_usd", "Available local Codex cost over the last 30 days in US dollars.", diff --git a/rust/src/cli/serve/metrics/rendering.rs b/rust/src/cli/serve/metrics/rendering.rs index d045f360b4c..bec63e952e7 100644 --- a/rust/src/cli/serve/metrics/rendering.rs +++ b/rust/src/cli/serve/metrics/rendering.rs @@ -133,6 +133,28 @@ pub(in crate::cli::serve::metrics) fn render_at( expiry.timestamp(), )?; } + // 来源与记录时间使使用者能识别登录时的历史声明,数值不携带身份信息。 + if let Some(end) = provider.subscription_period_end { + writer.sample( + "codexbar_subscription_period_end_timestamp_seconds", + &provider_labels, + end.timestamp(), + )?; + } + if let Some(recorded_at) = provider.subscription_recorded_at { + writer.sample( + "codexbar_subscription_dates_recorded_timestamp_seconds", + &provider_labels, + recorded_at.timestamp(), + )?; + } + if let Some(from_id_token) = provider.subscription_from_id_token { + writer.sample( + "codexbar_subscription_dates_from_id_token", + &provider_labels, + u8::from(from_id_token), + )?; + } if let Some(value) = provider.cost_today_usd { writer.sample_f64("codexbar_cost_today_usd", &provider_labels, value)?; } diff --git a/rust/src/cli/serve/metrics/snapshot.rs b/rust/src/cli/serve/metrics/snapshot.rs index f2e2967d7a8..6729a4b3c62 100644 --- a/rust/src/cli/serve/metrics/snapshot.rs +++ b/rust/src/cli/serve/metrics/snapshot.rs @@ -26,6 +26,9 @@ pub(in crate::cli::serve::metrics) struct ProviderMetrics { pub(in crate::cli::serve::metrics) reset_credits_next_expiry: Option>, pub(in crate::cli::serve::metrics) subscription_start: Option>, pub(in crate::cli::serve::metrics) subscription_expiry: Option>, + pub(in crate::cli::serve::metrics) subscription_period_end: Option>, + pub(in crate::cli::serve::metrics) subscription_recorded_at: Option>, + pub(in crate::cli::serve::metrics) subscription_from_id_token: Option, pub(in crate::cli::serve::metrics) cost_today_usd: Option, pub(in crate::cli::serve::metrics) cost_last_30_days_usd: Option, } @@ -104,11 +107,37 @@ impl MetricsSnapshot { subscription_start: (id == ProviderId::Codex) .then_some(result.usage.subscription.as_ref()) .flatten() - .and_then(|subscription| subscription.starts_at), + .and_then(|subscription| subscription.starts_at) + .or_else(|| { + (id == ProviderId::Codex && result.usage.subscription.is_none()) + .then_some(result.usage.credential_subscription_period) + .flatten() + .and_then(|period| period.starts_at) + }), subscription_expiry: (id == ProviderId::Codex) .then_some(result.usage.subscription.as_ref()) .flatten() .and_then(|subscription| subscription.expires_at), + // 周期终点独立导出,不能冒充取消续费后的订阅到期时间。 + subscription_period_end: (id == ProviderId::Codex + && result.usage.subscription.is_none()) + .then_some(result.usage.credential_subscription_period) + .flatten() + .and_then(|period| period.ends_at), + subscription_recorded_at: (id == ProviderId::Codex + && result.usage.subscription.is_none()) + .then_some(result.usage.credential_subscription_period) + .flatten() + .and_then(|period| period.recorded_at), + subscription_from_id_token: (id == ProviderId::Codex) + .then(|| { + if result.usage.subscription.is_some() { + Some(false) + } else { + result.usage.credential_subscription_period.map(|_| true) + } + }) + .flatten(), cost_today_usd, cost_last_30_days_usd, }, @@ -121,6 +150,9 @@ impl MetricsSnapshot { reset_credits_next_expiry: None, subscription_start: None, subscription_expiry: None, + subscription_period_end: None, + subscription_recorded_at: None, + subscription_from_id_token: None, cost_today_usd, cost_last_30_days_usd, }, diff --git a/rust/src/cli/serve/metrics/tests.rs b/rust/src/cli/serve/metrics/tests.rs index ff2aff6204e..64c3553422a 100644 --- a/rust/src/cli/serve/metrics/tests.rs +++ b/rust/src/cli/serve/metrics/tests.rs @@ -315,6 +315,96 @@ fn subscription_dates_are_omitted_when_provider_fetch_fails() { assert!(!body.contains("private error")); } +#[test] +fn subscription_period_exports_login_record_without_claiming_subscription_expiry() { + // 登录记录是周期信息,不能生成 expiresAt 或输出账号、令牌。 + let mut codex_usage = usage(RateWindow::new(10.0)); + codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + starts_at: Some(at(1)), + ends_at: Some(at(3)), + recorded_at: Some(at(0)), + }); + let body = render_at( + &metrics_snapshot( + vec![ + provider( + "codex", + Ok(ProviderFetchResult::new(codex_usage.clone(), "oauth")), + ), + provider("claude", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + ], + HashMap::new(), + ), + at(5), + ) + .unwrap(); + for (name, value) in [ + ( + "codexbar_subscription_start_timestamp_seconds", + at(1).timestamp(), + ), + ( + "codexbar_subscription_period_end_timestamp_seconds", + at(3).timestamp(), + ), + ( + "codexbar_subscription_dates_recorded_timestamp_seconds", + at(0).timestamp(), + ), + ("codexbar_subscription_dates_from_id_token", 1), + ] { + assert_eq!( + samples(&body, name).collect::>(), + vec![format!("{name}{{provider=\"codex\"}} {value}")] + ); + } + assert_eq!( + samples(&body, "codexbar_subscription_expiry_timestamp_seconds").count(), + 0 + ); + assert!(!body.contains("owner@example.test")); +} + +#[test] +fn subscription_period_api_dates_take_priority_over_login_records() { + let mut codex_usage = usage(RateWindow::new(10.0)).with_subscription(Some( + SubscriptionMetadata::new(Some(at(2)), Some(at(4)), None), + )); + codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + starts_at: Some(at(1)), + ends_at: Some(at(3)), + recorded_at: None, + }); + let body = render_at( + &metrics_snapshot( + vec![provider( + "codex", + Ok(ProviderFetchResult::new(codex_usage, "oauth")), + )], + HashMap::new(), + ), + at(5), + ) + .unwrap(); + assert!(body.contains(&format!( + "codexbar_subscription_start_timestamp_seconds{{provider=\"codex\"}} {}\n", + at(2).timestamp() + ))); + assert_eq!( + samples(&body, "codexbar_subscription_period_end_timestamp_seconds").count(), + 0 + ); + assert_eq!( + samples( + &body, + "codexbar_subscription_dates_recorded_timestamp_seconds" + ) + .count(), + 0 + ); + assert!(body.contains("codexbar_subscription_dates_from_id_token{provider=\"codex\"} 0\n")); +} + #[test] fn reset_credits_export_count_and_only_future_expiry() { let result = ProviderFetchResult::new(usage(RateWindow::new(10.0)), "oauth") diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 0de71b9bbf5..36f22472771 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -210,6 +210,15 @@ pub struct ResetCreditsObservation { pub next_expires_at: Option>, } +/// 登录凭据中明确记录的订阅周期,仅用于监控降级,不代表实时续费或取消状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialSubscriptionPeriod { + pub starts_at: Option>, + pub ends_at: Option>, + /// JWT iat 仅用于标记记录时间,绝不作为订阅周期边界。 + pub recorded_at: Option>, +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -308,6 +317,10 @@ pub struct UsageSnapshot { /// dashboard/API. These are not derived from quota reset windows. #[serde(default, skip_serializing_if = "Option::is_none")] pub subscription: Option, + + /// 临时监控数据,不进入持久化或桌面桥接;与确认的订阅到期日期分别建模。 + #[serde(skip)] + pub credential_subscription_period: Option, } impl UsageSnapshot { @@ -328,6 +341,7 @@ impl UsageSnapshot { account_organization: None, login_method: None, subscription: None, + credential_subscription_period: None, } } diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 50baa7c0155..4004862934a 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -12,6 +12,9 @@ mod credentials; mod parse; mod reset_credits; +#[cfg(test)] +mod subscription_period_tests; + use credentials::CodexCredentials; use parse::format_plan_type; use reset_credits::apply_reset_credits_window; @@ -97,6 +100,7 @@ impl CodexApi { &token, account_id.as_deref(), usage, + None, ) .await; Ok((usage, cost, account_identity)) @@ -187,6 +191,7 @@ impl CodexApi { &creds.access_token, creds.account_id.as_deref(), usage, + creds.subscription_period.as_ref(), ) .await; let usage = with_credential_email(usage, &creds); @@ -244,6 +249,7 @@ impl CodexApi { &creds.access_token, creds.account_id.as_deref(), usage, + creds.subscription_period.as_ref(), ) .await; let usage = apply_reset_credits_window(usage, displayed_credits.as_ref()); diff --git a/rust/src/providers/codex/api/credentials.rs b/rust/src/providers/codex/api/credentials.rs index ccb4232576a..9f6902bf1a1 100644 --- a/rust/src/providers/codex/api/credentials.rs +++ b/rust/src/providers/codex/api/credentials.rs @@ -1,5 +1,5 @@ use super::CodexApi; -use crate::core::ProviderError; +use crate::core::{CredentialSubscriptionPeriod, ProviderError}; use base64::Engine; use chrono::{DateTime, TimeZone, Utc}; use std::path::PathBuf; @@ -112,6 +112,7 @@ impl CodexApi { access_token_expires_at: None, last_refresh: None, account_email: None, + subscription_period: None, }); } } @@ -153,6 +154,10 @@ impl CodexApi { .get("id_token") .and_then(|v| v.as_str()) .and_then(crate::codex_accounts::credentials::email_from_id_token); + let subscription_period = tokens + .get("id_token") + .and_then(|value| value.as_str()) + .and_then(|token| parse_id_token_subscription_period(token, account_id.as_deref())); Ok(CodexCredentials { access_token, @@ -161,6 +166,7 @@ impl CodexApi { access_token_expires_at, last_refresh, account_email, + subscription_period, }) } @@ -259,6 +265,55 @@ pub(super) struct CodexCredentials { /// Account email from the auth.json `id_token` (claim `email`, or the /// OpenAI profile claim). Shown in the card header; never logged. pub(super) account_email: Option, + /// 身份匹配的 id_token 中的显式周期,不包含原始令牌。 + pub(super) subscription_period: Option, +} + +fn parse_id_token_subscription_period( + token: &str, + account_id: Option<&str>, +) -> Option { + // 只有完整 JWT 与当前凭据的账号一致才接受日期;不将本地解码当作签名验证。 + // 调用方还必须成功获取同一凭据的用量,才会把这些历史声明用于监控。 + let mut segments = token.split('.'); + segments.next()?; + let payload = segments.next()?; + segments.next()?; + if segments.next().is_some() { + return None; + } + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + let auth = claims.get("https://api.openai.com/auth")?; + let expected = account_id.filter(|id| !id.is_empty())?; + if auth.get("chatgpt_account_id")?.as_str()? != expected { + return None; + } + let date = |key| match auth.get(key) { + None | Some(serde_json::Value::Null) => Some(None), + Some(serde_json::Value::String(raw)) => parse_timestamp(raw).map(Some), + _ => None, + }; + let starts_at = date("chatgpt_subscription_active_start")?; + let ends_at = date("chatgpt_subscription_active_until")?; + if (starts_at.is_none() && ends_at.is_none()) + || starts_at + .zip(ends_at) + .is_some_and(|(start, end)| end <= start) + { + return None; + } + Some(CredentialSubscriptionPeriod { + starts_at, + ends_at, + recorded_at: claims + .get("iat") + .and_then(serde_json::Value::as_i64) + .and_then(|at| Utc.timestamp_opt(at, 0).single()), + }) } struct CachedCodexCredentials { diff --git a/rust/src/providers/codex/api/subscription_period_tests.rs b/rust/src/providers/codex/api/subscription_period_tests.rs new file mode 100644 index 00000000000..d2b4453a7d0 --- /dev/null +++ b/rust/src/providers/codex/api/subscription_period_tests.rs @@ -0,0 +1,105 @@ +use super::*; +use base64::Engine; +use serde_json::json; + +fn credentials_with_claims(claims: serde_json::Value, account: &str) -> CodexCredentials { + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).unwrap()); + CodexApi::parse_credentials_json( + &json!({"tokens": {"access_token": "opaque", "account_id": account, + "id_token": format!("header.{payload}.signature")}}) + .to_string(), + ) + .unwrap() +} + +#[test] +fn subscription_period_reads_explicit_id_token_dates_without_inventing_expiry() { + // 复现现场:用量成功,订阅请求不可用,而 id_token 明确包含周期日期。 + let credentials = credentials_with_claims( + json!({ + "iat": 1_790_000_000, "exp": 1_990_000_000, + "https://api.openai.com/auth": { + "chatgpt_account_id": "account-a", + "chatgpt_subscription_active_start": "2026-10-06T13:04:47Z", + "chatgpt_subscription_active_until": "2026-11-06T13:04:47Z" + } + }), + "account-a", + ); + let period = credentials.subscription_period.expect("explicit dates"); + assert_eq!( + period.starts_at.unwrap().to_rfc3339(), + "2026-10-06T13:04:47+00:00" + ); + assert_eq!( + period.ends_at.unwrap().to_rfc3339(), + "2026-11-06T13:04:47+00:00" + ); + assert_eq!(period.recorded_at.unwrap().timestamp(), 1_790_000_000); + + let usage = UsageSnapshot::new(crate::core::RateWindow::new(12.0)); + let usage = subscription::apply_subscription_result( + usage, + crate::providers::openai::OpenAISubscriptionFetchResult::Unavailable, + Some(&period), + ); + assert!(usage.subscription.is_none()); + assert!(usage.credential_subscription_period.is_some()); + let serialized = serde_json::to_string(&usage).unwrap(); + assert!(!serialized.contains("credential_subscription_period")); + assert!(!serialized.contains("2026-11-06")); +} + +#[test] +fn subscription_period_rejects_another_account_and_malformed_or_inverted_dates() { + for (account, start, end) in [ + ("account-b", "2026-10-06T13:04:47Z", "2026-11-06T13:04:47Z"), + ("account-a", "bad-date", "2026-11-06T13:04:47Z"), + ("account-a", "2026-12-06T13:04:47Z", "2026-11-06T13:04:47Z"), + ] { + let credentials = credentials_with_claims( + json!({"https://api.openai.com/auth": { + "chatgpt_account_id": account, + "chatgpt_subscription_active_start": start, + "chatgpt_subscription_active_until": end + }}), + "account-a", + ); + assert!(credentials.subscription_period.is_none()); + } + // 单独的 JWT exp/iat、缺失身份和空日期都不能构成订阅周期。 + for claims in [ + json!({"exp": 1_990_000_000, "iat": 1_790_000_000}), + json!({"https://api.openai.com/auth": {"chatgpt_subscription_active_until": "2026-11-06T13:04:47Z"}}), + json!({"https://api.openai.com/auth": {"chatgpt_account_id": "account-a"}}), + ] { + assert!( + credentials_with_claims(claims, "account-a") + .subscription_period + .is_none() + ); + } +} + +#[test] +fn subscription_period_successful_api_response_suppresses_credential_fallback() { + use crate::core::{CredentialSubscriptionPeriod, RateWindow, SubscriptionMetadata}; + use crate::providers::openai::OpenAISubscriptionFetchResult; + let at = super::credentials::parse_timestamp("2026-11-06T13:04:47Z").unwrap(); + let period = CredentialSubscriptionPeriod { + starts_at: None, + ends_at: Some(at), + recorded_at: None, + }; + for metadata in [None, Some(SubscriptionMetadata::new(None, None, Some(at)))] { + // 明确的空订阅结果必须压过旧登录记录,防止已取消订阅被重新填回。 + let usage = subscription::apply_subscription_result( + UsageSnapshot::new(RateWindow::new(1.0)), + OpenAISubscriptionFetchResult::Success(metadata.clone()), + Some(&period), + ); + assert_eq!(usage.subscription, metadata); + assert!(usage.credential_subscription_period.is_none()); + } +} diff --git a/rust/src/providers/codex/api/tests.rs b/rust/src/providers/codex/api/tests.rs index 05cc4d9bdaf..08ca1106f2e 100644 --- a/rust/src/providers/codex/api/tests.rs +++ b/rust/src/providers/codex/api/tests.rs @@ -160,6 +160,7 @@ fn external_creds(access_token: &str, last_refresh: Option>) -> Co access_token_expires_at: None, last_refresh, account_email: None, + subscription_period: None, } } @@ -1051,6 +1052,7 @@ fn credential_email_fills_the_snapshot_but_never_overrides_one() { access_token_expires_at: None, last_refresh: None, account_email: Some("parity.user@example.com".to_string()), + subscription_period: None, }; let usage = with_credential_email(UsageSnapshot::new(RateWindow::new(10.0)), &creds); assert_eq!( diff --git a/rust/src/providers/codex/subscription.rs b/rust/src/providers/codex/subscription.rs index 175cdd90a3f..a5b3d63c609 100644 --- a/rust/src/providers/codex/subscription.rs +++ b/rust/src/providers/codex/subscription.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use crate::core::UsageSnapshot; +use crate::core::{CredentialSubscriptionPeriod, UsageSnapshot}; use crate::providers::openai::{OpenAISubscriptionFetchResult, parse_subscription_http_response}; use super::CodexApi; @@ -17,13 +17,32 @@ pub(super) async fn enrich_subscription_metadata( access_token: &str, account_id: Option<&str>, usage: UsageSnapshot, + credential_period: Option<&CredentialSubscriptionPeriod>, ) -> UsageSnapshot { if !crate::settings::Settings::load().codex_openai_web_extras() { return usage; } - match fetch_subscription_metadata(api, base_url, access_token, account_id).await { + // 自定义用量后端不能证明这些凭据属于它返回的账号,因此不附加登录周期。 + if !is_subscription_authority(base_url) { + return usage; + } + let result = fetch_subscription_metadata(api, base_url, access_token, account_id).await; + apply_subscription_result(usage, result, credential_period) +} + +pub(super) fn apply_subscription_result( + mut usage: UsageSnapshot, + result: OpenAISubscriptionFetchResult, + credential_period: Option<&CredentialSubscriptionPeriod>, +) -> UsageSnapshot { + // 成功响应(包括明确的空结果)始终优先;仅不可用时挂载独立的登录周期记录。 + usage.credential_subscription_period = None; + match result { OpenAISubscriptionFetchResult::Success(metadata) => usage.with_subscription(metadata), - OpenAISubscriptionFetchResult::Unavailable => usage, + OpenAISubscriptionFetchResult::Unavailable => { + usage.credential_subscription_period = credential_period.copied(); + usage + } } } @@ -33,16 +52,6 @@ async fn fetch_subscription_metadata( access_token: &str, account_id: Option<&str>, ) -> OpenAISubscriptionFetchResult { - let Some(host) = reqwest::Url::parse(base_url) - .ok() - .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) - else { - return OpenAISubscriptionFetchResult::Unavailable; - }; - if !matches!(host.as_str(), "chatgpt.com" | "chat.openai.com") { - return OpenAISubscriptionFetchResult::Unavailable; - } - let request = api .authed_get( &format!("{}{}", base_url.trim_end_matches('/'), SUBSCRIPTION_PATH), @@ -52,10 +61,19 @@ async fn fetch_subscription_metadata( .header("Cache-Control", "no-cache, no-store, max-age=0") .header("Pragma", "no-cache") .timeout(Duration::from_secs(8)); - let Ok(response) = request.send().await else { - return OpenAISubscriptionFetchResult::Unavailable; + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + // 不记录错误原文或响应体,避免泄露凭据和账号;保留可区分的失败类别。 + tracing::debug!(timeout = error.is_timeout(), "Codex 订阅查询不可用"); + return OpenAISubscriptionFetchResult::Unavailable; + } }; let status = response.status().as_u16(); + if !(200..300).contains(&status) { + tracing::debug!(http_status = status, "Codex 订阅查询返回非成功状态"); + return OpenAISubscriptionFetchResult::Unavailable; + } let Ok(body) = response.bytes().await else { return OpenAISubscriptionFetchResult::Unavailable; }; @@ -64,3 +82,15 @@ async fn fetch_subscription_metadata( }; parse_subscription_http_response(status, body) } + +pub(super) fn is_subscription_authority(base_url: &str) -> bool { + reqwest::Url::parse(base_url).is_ok_and(|url| { + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && matches!(url.host_str(), Some("chatgpt.com" | "chat.openai.com")) + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) +} From 72053f3d311bbae53a0943d0bbc8e6d31e30db87 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:36:26 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E7=B2=BE=E7=AE=80=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E8=A1=A8=E4=B8=BA=E5=BC=80=E5=A7=8B=E5=92=8C=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/grafana/codexbar-codex-zh.json | 135 ++-------------------------- docs/grafana/test_queries.py | 22 ++--- docs/prometheus/README.zh-CN.md | 4 +- 3 files changed, 22 insertions(+), 139 deletions(-) diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index 987057e947a..3f51b88fc32 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -978,7 +978,7 @@ "type": "prometheus", "uid": "${datasource}" }, - "description": "订阅接口成功时显示其明确提供的开始和到期时间。接口不可用时,可显示同账号登录凭据中明确记录的订阅开始和周期结束时间,日期来源标为“登录记录”;登录记录可能滞后,周期结束不表示已取消续费,也不保证届时停用。日期记录时间为该登录声明的签发时间,不是本次采集时间。缺失时显示“未提供”;时间沿用仪表盘时区,默认浏览器本地时区。", + "description": "按节点展示订阅开始和结束时间。结束时间优先采用订阅接口明确提供的到期时间,未提供时采用登录记录中的周期结束时间。登录记录可能滞后,周期结束不代表已取消续费或账号届时停用。时间沿用仪表盘时区,默认浏览器本地时区;缺失时显示“未提供”。", "fieldConfig": { "defaults": { "custom": { @@ -1019,7 +1019,7 @@ { "matcher": { "id": "byName", - "options": "订阅到期时间" + "options": "订阅结束时间" }, "properties": [ { @@ -1041,87 +1041,6 @@ ] } ] - }, - { - "matcher": { - "id": "byName", - "options": "周期结束时间" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsIso" - }, - { - "id": "mappings", - "value": [ - { - "options": { - "0": { - "color": "orange", - "text": "未提供" - } - }, - "type": "value" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "日期记录时间" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsIso" - }, - { - "id": "mappings", - "value": [ - { - "options": { - "0": { - "color": "orange", - "text": "未提供" - } - }, - "type": "value" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "日期来源" - }, - "properties": [ - { - "id": "mappings", - "value": [ - { - "type": "value", - "options": { - "0": { - "text": "订阅接口" - }, - "1": { - "text": "登录记录", - "color": "orange" - }, - "-1": { - "text": "未提供", - "color": "orange" - } - } - } - ] - } - ] } ] }, @@ -1156,39 +1075,12 @@ }, { "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", + "expr": "((max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", "format": "table", "instant": true, - "legendFormat": "订阅到期时间", + "legendFormat": "订阅结束时间", "range": false, "refId": "B" - }, - { - "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", - "format": "table", - "instant": true, - "legendFormat": "周期结束时间", - "range": false, - "refId": "C" - }, - { - "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_dates_from_id_token{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0 - 1)", - "format": "table", - "instant": true, - "legendFormat": "日期来源", - "range": false, - "refId": "D" - }, - { - "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_dates_recorded_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", - "format": "table", - "instant": true, - "legendFormat": "日期记录时间", - "range": false, - "refId": "E" } ], "title": "Codex 订阅信息", @@ -1210,26 +1102,17 @@ "__name__": true, "job": true, "monitor": true, - "provider": true, - "Time #C": true, - "Time #D": true, - "Time #E": true + "provider": true }, "indexByName": { "instance": 0, "Value #A": 1, - "Value #C": 2, - "Value #B": 3, - "Value #D": 4, - "Value #E": 5 + "Value #B": 2 }, "renameByName": { - "Value #A": "订阅开始时间", - "Value #B": "订阅到期时间", "instance": "节点", - "Value #C": "周期结束时间", - "Value #D": "日期来源", - "Value #E": "日期记录时间" + "Value #A": "订阅开始时间", + "Value #B": "订阅结束时间" } } } @@ -1599,7 +1482,7 @@ "timezone": "browser", "title": "CodexBar · Codex 监控", "uid": null, - "version": 2, + "version": 3, "weekStart": "", "description": "Codex 多节点中文监控:剩余额度、重置倒计时、重置卡与采集状态。适配上游固定窗口 ratio 指标。" } diff --git a/docs/grafana/test_queries.py b/docs/grafana/test_queries.py index ca025508d4e..75debeb2a05 100644 --- a/docs/grafana/test_queries.py +++ b/docs/grafana/test_queries.py @@ -78,18 +78,18 @@ def test_explicit_regex_formatter_reproduces_reported_failure(self): with self.assertRaises(json.JSONDecodeError): json.loads(expanded[len('up{instance=~'):-1]) - def test_subscription_period_and_expiry_keep_distinct_columns_and_source(self): - # 登录记录可能滞后,周期结束和确认的到期日期必须分列并标记来源。 + def test_subscription_table_only_shows_start_and_end(self): + # 展示合并为开始和结束两列,保留接口到期优先、登录周期备用的语义。 panel = next(panel for panel in self.dashboard["panels"] if panel.get("title") == "Codex 订阅信息") - expressions = "\n".join(target["expr"] for target in panel["targets"]) - for name in ("codexbar_subscription_period_end_timestamp_seconds", - "codexbar_subscription_expiry_timestamp_seconds", - "codexbar_subscription_dates_from_id_token", - "codexbar_subscription_dates_recorded_timestamp_seconds"): - self.assertIn(name, expressions) - names = panel["transformations"][1]["options"]["renameByName"].values() - for column in ("订阅开始时间", "订阅到期时间", "周期结束时间", "日期来源", "日期记录时间"): - self.assertIn(column, names) + self.assertEqual([target["refId"] for target in panel["targets"]], ["A", "B"]) + end_query = panel["targets"][1]["expr"] + self.assertLess(end_query.index("codexbar_subscription_expiry_timestamp_seconds"), + end_query.index("codexbar_subscription_period_end_timestamp_seconds")) + self.assertIn("or on(instance)", end_query) + self.assertNotIn("dates_from_id_token", end_query) + names = panel["transformations"][1]["options"]["renameByName"] + self.assertEqual(names, {"instance": "节点", "Value #A": "订阅开始时间", "Value #B": "订阅结束时间"}) + self.assertIn("登录记录可能滞后", panel["description"]) if __name__ == "__main__": diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index af89b6568a7..6fd189d890b 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -21,7 +21,7 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I ## 订阅开始和到期时间 -“Codex 订阅信息”表按节点展示以下字段,独立于额度窗口筛选: +“Codex 订阅信息”表仅显示“节点、订阅开始时间、订阅结束时间”三列,独立于额度窗口筛选。结束时间优先使用接口明确提供的到期时间,未提供时使用登录记录中的周期结束时间;登录记录可能滞后,具体含义在表格说明中提示。指标仍分别保留以下字段,供需要详细信息的查询使用: | 订阅字段 | Prometheus 指标 | 含义 | | --- | --- | --- | @@ -37,7 +37,7 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I 当订阅接口不可用时,OAuth 用量采集成功后可使用同一 `auth.json` 的 `id_token` 中明确记录的周期:`chatgpt_subscription_active_start` 作为开始时间,`chatgpt_subscription_active_until` 作为单独的“周期结束时间”。只有令牌声明的账号 ID 与当前凭据账号 ID 一致才接受;身份缺失、不匹配、日期格式错误或周期倒置时均忽略。PAT、API Key 和自定义后端不使用此降级来源。 -登录记录只反映令牌签发时的订阅周期,可能滞后,不能据此确认自动续费、取消订阅或当前订阅状态。面板显示“登录记录”和可用的日期记录时间;旧周期结束日期仍保留,不能仅凭它告警“账号已到期”。不读取或更改刷新令牌、不触发登录,不增加网络请求。新的周期信息在 Codex 自己更新凭据后随下一次正常采集读取。 +登录记录只反映令牌签发时的订阅周期,可能滞后,不能据此确认自动续费、取消订阅或当前订阅状态。来源和记录时间仍可通过上述指标查询;面板只显示开始和结束时间。旧周期结束日期仍保留,不能仅凭它告警“账号已到期”。不读取或更改刷新令牌、不触发登录,不增加网络请求。新的周期信息在 Codex 自己更新凭据后随下一次正常采集读取。 登录周期仅通过 `/metrics` 导出,不写入持久化或通用 `subscription.expiresAt` 字段,也不进入桌面订阅到期提示。未取得日期时省略对应样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期、JWT `exp` 或续费时间推算订阅到期时间。 From 96a4a62ed28c10d05d4b9b2573693189134b1138 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:21:38 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=9F=A5=E8=AF=A2=E5=92=8C=E7=BB=AD=E8=B4=B9?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/grafana/codexbar-codex-zh.json | 4 +- docs/grafana/test_queries.py | 4 +- docs/prometheus/README.zh-CN.md | 7 +- docs/prometheus/openapi.yaml | 5 +- rust/src/cli/serve/metrics/definitions.rs | 4 ++ rust/src/cli/serve/metrics/rendering.rs | 8 +++ rust/src/cli/serve/metrics/snapshot.rs | 7 ++ rust/src/cli/serve/metrics/tests.rs | 53 +++++++++++++++ rust/src/codex_accounts/subscription.rs | 82 +++++++++++++++++++---- rust/src/providers/codex/subscription.rs | 59 +++++++++++++++- 10 files changed, 213 insertions(+), 20 deletions(-) diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index 3f51b88fc32..8fe4553c993 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -978,7 +978,7 @@ "type": "prometheus", "uid": "${datasource}" }, - "description": "按节点展示订阅开始和结束时间。结束时间优先采用订阅接口明确提供的到期时间,未提供时采用登录记录中的周期结束时间。登录记录可能滞后,周期结束不代表已取消续费或账号届时停用。时间沿用仪表盘时区,默认浏览器本地时区;缺失时显示“未提供”。", + "description": "按节点展示订阅开始和结束时间。结束时间优先采用订阅接口明确提供的到期时间,自动续费账号使用下一次续费时间,接口不可用时采用登录记录中的周期结束时间。登录记录可能滞后,续费时间和周期结束不代表账号届时停用。时间沿用仪表盘时区,默认浏览器本地时区;缺失时显示“未提供”。", "fieldConfig": { "defaults": { "custom": { @@ -1075,7 +1075,7 @@ }, { "editorMode": "code", - "expr": "((max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", + "expr": "((max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_renewal_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", "format": "table", "instant": true, "legendFormat": "订阅结束时间", diff --git a/docs/grafana/test_queries.py b/docs/grafana/test_queries.py index 75debeb2a05..2084b31d66a 100644 --- a/docs/grafana/test_queries.py +++ b/docs/grafana/test_queries.py @@ -79,11 +79,13 @@ def test_explicit_regex_formatter_reproduces_reported_failure(self): json.loads(expanded[len('up{instance=~'):-1]) def test_subscription_table_only_shows_start_and_end(self): - # 展示合并为开始和结束两列,保留接口到期优先、登录周期备用的语义。 + # 展示保持开始和结束两列,优先采用接口到期、续费日期,登录周期备用。 panel = next(panel for panel in self.dashboard["panels"] if panel.get("title") == "Codex 订阅信息") self.assertEqual([target["refId"] for target in panel["targets"]], ["A", "B"]) end_query = panel["targets"][1]["expr"] self.assertLess(end_query.index("codexbar_subscription_expiry_timestamp_seconds"), + end_query.index("codexbar_subscription_renewal_timestamp_seconds")) + self.assertLess(end_query.index("codexbar_subscription_renewal_timestamp_seconds"), end_query.index("codexbar_subscription_period_end_timestamp_seconds")) self.assertIn("or on(instance)", end_query) self.assertNotIn("dates_from_id_token", end_query) diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index 6fd189d890b..91a46f13dc1 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -21,19 +21,22 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I ## 订阅开始和到期时间 -“Codex 订阅信息”表仅显示“节点、订阅开始时间、订阅结束时间”三列,独立于额度窗口筛选。结束时间优先使用接口明确提供的到期时间,未提供时使用登录记录中的周期结束时间;登录记录可能滞后,具体含义在表格说明中提示。指标仍分别保留以下字段,供需要详细信息的查询使用: +“Codex 订阅信息”表仅显示“节点、订阅开始时间、订阅结束时间”三列,独立于额度窗口筛选。结束时间按“接口到期时间 → 接口续费时间 → 登录记录中的周期结束时间”取值;自动续费账号显示下一次续费日期,不表示届时停用。登录记录可能滞后,具体含义在表格说明中提示。指标仍分别保留以下字段,供需要详细信息的查询使用: | 订阅字段 | Prometheus 指标 | 含义 | | --- | --- | --- | | `startsAt` | `codexbar_subscription_start_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅开始时间 | | `expiresAt` | `codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` | 接口明确提供的订阅到期时间;已过期的日期仍保留 | +| `renewsAt` | `codexbar_subscription_renewal_timestamp_seconds{provider="codex"}` | 自动续费账号下一次续费时间;独立于到期时间 | | 登录记录中的周期终点 | `codexbar_subscription_period_end_timestamp_seconds{provider="codex"}` | `id_token` 明确提供的 `chatgpt_subscription_active_until`;不等于确认的取消续费到期时间 | | 日期来源 | `codexbar_subscription_dates_from_id_token{provider="codex"}` | `1` 为登录记录,`0` 为订阅接口;无日期时省略 | | 日期记录时间 | `codexbar_subscription_dates_recorded_timestamp_seconds{provider="codex"}` | 登录声明的签发时间 `iat`;不是监控采集时间 | 指标值是 Unix 秒时间戳,Grafana 查询乘以 1000 后显示日期,时间沿用仪表盘时区(默认浏览器本地时区)。这些字段来自现有采集结果,抓取 `/metrics` 不会另外请求订阅接口。 -需开启设置中的“OpenAI 网页附加功能”(默认开启)。订阅接口成功时始终优先,包括明确返回空订阅的情况。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充到期时间。 +需开启设置中的“OpenAI 网页附加功能”(默认开启)。订阅查询使用现有 OAuth 凭据,在 `/backend-api/subscriptions` 同时传入 URL 编码后的 `account_id` 查询参数与相同的 `ChatGPT-Account-Id` 请求头;CLI 和托管账号路径一致。仅传请求头可能返回 HTTP 400。查询保留 8 秒超时及失败降级,不新增抓取请求或凭据刷新。 + +订阅接口成功时始终优先,包括明确返回空订阅的情况。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充 `expiresAt`;面板会使用独立的续费指标,避免继续展示旧登录周期。接口未提供开始时间时,面板显示“未提供”,不从下一次续费日倒推。 当订阅接口不可用时,OAuth 用量采集成功后可使用同一 `auth.json` 的 `id_token` 中明确记录的周期:`chatgpt_subscription_active_start` 作为开始时间,`chatgpt_subscription_active_until` 作为单独的“周期结束时间”。只有令牌声明的账号 ID 与当前凭据账号 ID 一致才接受;身份缺失、不匹配、日期格式错误或周期倒置时均忽略。PAT、API Key 和自定义后端不使用此降级来源。 diff --git a/docs/prometheus/openapi.yaml b/docs/prometheus/openapi.yaml index 885c37675a2..5f2dc2a831a 100644 --- a/docs/prometheus/openapi.yaml +++ b/docs/prometheus/openapi.yaml @@ -9,7 +9,8 @@ paths: summary: 获取 Prometheus 文本指标 description: >- 沿用服务的 Host 白名单和 Bearer 认证。订阅开始、到期时间为 Unix 秒时间戳; - 未取得日期时省略相应样本。自动续费日期不作为到期时间。 + 未取得日期时省略相应样本。自动续费日期通过 renewal_timestamp_seconds 独立导出, + 不作为 expiry_timestamp_seconds 到期时间;Grafana 结束时间可回退使用续费日期。 订阅接口不可用时可导出同账号登录凭据明确记录的周期;周期结束独立于到期时间。 dates_from_id_token 为 1 表示登录记录、0 表示订阅接口;登录记录可能滞后, dates_recorded_timestamp_seconds 标记其签发时间,而非本次监控采集时间。 @@ -28,6 +29,8 @@ paths: codexbar_subscription_start_timestamp_seconds{provider="codex"} 1790812800 # TYPE codexbar_subscription_expiry_timestamp_seconds gauge codexbar_subscription_expiry_timestamp_seconds{provider="codex"} 1793491200 + # TYPE codexbar_subscription_renewal_timestamp_seconds gauge + codexbar_subscription_renewal_timestamp_seconds{provider="codex"} 1796100935 '401': description: 未提供有效的 Bearer 令牌 '403': diff --git a/rust/src/cli/serve/metrics/definitions.rs b/rust/src/cli/serve/metrics/definitions.rs index a20acf8b3b2..772d2a3146a 100644 --- a/rust/src/cli/serve/metrics/definitions.rs +++ b/rust/src/cli/serve/metrics/definitions.rs @@ -110,6 +110,10 @@ pub(in crate::cli::serve::metrics) const METRIC_DEFINITIONS: &[(&str, &str)] = & "codexbar_subscription_expiry_timestamp_seconds", "Codex 订阅到期时间的 Unix 秒时间戳,包含已过期的日期。", ), + ( + "codexbar_subscription_renewal_timestamp_seconds", + "Codex 自动续费账号下一次续费时间的 Unix 秒时间戳,不表示届时停用。", + ), ( "codexbar_subscription_period_end_timestamp_seconds", "Codex 登录凭据记录的订阅周期结束时间,不代表已确认取消后的到期时间。", diff --git a/rust/src/cli/serve/metrics/rendering.rs b/rust/src/cli/serve/metrics/rendering.rs index bec63e952e7..93e0d0fbbf6 100644 --- a/rust/src/cli/serve/metrics/rendering.rs +++ b/rust/src/cli/serve/metrics/rendering.rs @@ -133,6 +133,14 @@ pub(in crate::cli::serve::metrics) fn render_at( expiry.timestamp(), )?; } + // 续费日期独立输出,避免自动续费账号回退显示登录时的旧周期。 + if let Some(renewal) = provider.subscription_renewal { + writer.sample( + "codexbar_subscription_renewal_timestamp_seconds", + &provider_labels, + renewal.timestamp(), + )?; + } // 来源与记录时间使使用者能识别登录时的历史声明,数值不携带身份信息。 if let Some(end) = provider.subscription_period_end { writer.sample( diff --git a/rust/src/cli/serve/metrics/snapshot.rs b/rust/src/cli/serve/metrics/snapshot.rs index 6729a4b3c62..dc40e119f47 100644 --- a/rust/src/cli/serve/metrics/snapshot.rs +++ b/rust/src/cli/serve/metrics/snapshot.rs @@ -26,6 +26,7 @@ pub(in crate::cli::serve::metrics) struct ProviderMetrics { pub(in crate::cli::serve::metrics) reset_credits_next_expiry: Option>, pub(in crate::cli::serve::metrics) subscription_start: Option>, pub(in crate::cli::serve::metrics) subscription_expiry: Option>, + pub(in crate::cli::serve::metrics) subscription_renewal: Option>, pub(in crate::cli::serve::metrics) subscription_period_end: Option>, pub(in crate::cli::serve::metrics) subscription_recorded_at: Option>, pub(in crate::cli::serve::metrics) subscription_from_id_token: Option, @@ -118,6 +119,11 @@ impl MetricsSnapshot { .then_some(result.usage.subscription.as_ref()) .flatten() .and_then(|subscription| subscription.expires_at), + // 自动续费日单独投影,保留 expiresAt 的取消续费到期语义。 + subscription_renewal: (id == ProviderId::Codex) + .then_some(result.usage.subscription.as_ref()) + .flatten() + .and_then(|subscription| subscription.renews_at), // 周期终点独立导出,不能冒充取消续费后的订阅到期时间。 subscription_period_end: (id == ProviderId::Codex && result.usage.subscription.is_none()) @@ -150,6 +156,7 @@ impl MetricsSnapshot { reset_credits_next_expiry: None, subscription_start: None, subscription_expiry: None, + subscription_renewal: None, subscription_period_end: None, subscription_recorded_at: None, subscription_from_id_token: None, diff --git a/rust/src/cli/serve/metrics/tests.rs b/rust/src/cli/serve/metrics/tests.rs index 64c3553422a..ee980e1a2cf 100644 --- a/rust/src/cli/serve/metrics/tests.rs +++ b/rust/src/cli/serve/metrics/tests.rs @@ -230,6 +230,59 @@ fn subscription_dates_export_explicit_start_and_expiry_even_after_expiration() { } } +#[test] +fn subscription_renewal_exports_live_date_without_old_period_or_false_expiry() { + use crate::providers::openai::{ + OpenAISubscriptionFetchResult, parse_subscription_http_response, + }; + + // 184 的实时 active_until 表示下一次续费,旧 id_token 的 10 月周期不能继续展示。 + let OpenAISubscriptionFetchResult::Success(metadata) = parse_subscription_http_response( + 200, + r#"{"active_until":"2026-12-01T04:55:35Z","will_renew":true}"#, + ) else { + panic!("有效的订阅响应"); + }; + let mut codex_usage = usage(RateWindow::new(10.0)).with_subscription(metadata); + codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + starts_at: None, + ends_at: Some("2026-10-01T03:17:52Z".parse().unwrap()), + recorded_at: None, + }); + let body = render_at( + &metrics_snapshot( + vec![ + provider( + "codex", + Ok(ProviderFetchResult::new(codex_usage.clone(), "oauth")), + ), + provider("claude", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + ], + HashMap::new(), + ), + at(2), + ) + .unwrap(); + let renewal = "codexbar_subscription_renewal_timestamp_seconds"; + let expected: chrono::DateTime = "2026-12-01T04:55:35Z".parse().unwrap(); + assert_eq!( + samples(&body, renewal).collect::>(), + vec![format!( + "{renewal}{{provider=\"codex\"}} {}", + expected.timestamp() + )] + ); + assert!(body.contains(&format!("# TYPE {renewal} gauge\n"))); + for name in [ + "codexbar_subscription_expiry_timestamp_seconds", + "codexbar_subscription_period_end_timestamp_seconds", + "codexbar_subscription_start_timestamp_seconds", + ] { + assert_eq!(samples(&body, name).count(), 0); + } + assert!(body.contains("codexbar_subscription_dates_from_id_token{provider=\"codex\"} 0\n")); +} + #[test] fn subscription_dates_do_not_infer_expiry_from_renewal_or_quota_reset() { // 自动续费日期和额度重置日期都不能代替订阅到期时间。 diff --git a/rust/src/codex_accounts/subscription.rs b/rust/src/codex_accounts/subscription.rs index 991246567ad..b05d9e6abf7 100644 --- a/rust/src/codex_accounts/subscription.rs +++ b/rust/src/codex_accounts/subscription.rs @@ -54,6 +54,29 @@ pub(super) async fn fetch_subscription_metadata( let Some(url) = resolve_subscription_url(codex_home_path) else { return OpenAISubscriptionFetchResult::Unavailable; }; + let Ok(response) = subscription_request(api, &url, credentials, account_id) + .send() + .await + else { + return OpenAISubscriptionFetchResult::Unavailable; + }; + let status = response.status().as_u16(); + let Ok(body) = response.bytes().await else { + return OpenAISubscriptionFetchResult::Unavailable; + }; + let Ok(body) = std::str::from_utf8(&body) else { + return OpenAISubscriptionFetchResult::Unavailable; + }; + parse_subscription_http_response(status, body) +} + +// 将请求构造与发送分开,便于在不读取凭据、不访问账号接口时验证请求契约。 +fn subscription_request( + api: &CodexAccountApi, + url: &str, + credentials: &AuthCredentials, + account_id: Option<&str>, +) -> reqwest::RequestBuilder { let mut request = api .client .get(url) @@ -67,19 +90,12 @@ pub(super) async fn fetch_subscription_metadata( .header("Pragma", "no-cache") .timeout(std::time::Duration::from_secs(8)); if let Some(account_id) = account_id.filter(|id| !id.is_empty()) { - request = request.header("ChatGPT-Account-Id", account_id); + // 托管账号与 CLI 使用相同契约;查询参数必须指向当前选中的工作区账号。 + request = request + .header("ChatGPT-Account-Id", account_id) + .query(&[("account_id", account_id)]); } - let Ok(response) = request.send().await else { - return OpenAISubscriptionFetchResult::Unavailable; - }; - let status = response.status().as_u16(); - let Ok(body) = response.bytes().await else { - return OpenAISubscriptionFetchResult::Unavailable; - }; - let Ok(body) = std::str::from_utf8(&body) else { - return OpenAISubscriptionFetchResult::Unavailable; - }; - parse_subscription_http_response(status, body) + request } pub(super) fn resolve_subscription_url(codex_home_path: &Path) -> Option { @@ -101,7 +117,47 @@ pub(super) fn resolve_subscription_url(codex_home_path: &Path) -> Option #[cfg(test)] mod tests { - use super::resolve_subscription_url; + use super::*; + + #[test] + fn subscription_account_query_matches_header_and_encodes_account_id() { + // 复现 184:仅有账号请求头会返回 400,接口还要求同账号的查询参数。 + let api = CodexAccountApi { + client: reqwest::Client::builder().no_proxy().build().unwrap(), + }; + let credentials = AuthCredentials { + access_token: "synthetic-token".into(), + refresh_token: String::new(), + id_token: None, + account_id: None, + last_refresh: None, + }; + for account_id in [Some("workspace-test+id&x=1/?"), None, Some("")] { + let request = subscription_request( + &api, + "https://chatgpt.com/backend-api/subscriptions", + &credentials, + account_id, + ) + .build() + .unwrap(); + let account_id = account_id.filter(|id| !id.is_empty()); + assert_eq!( + request + .headers() + .get("ChatGPT-Account-Id") + .map(|id| id.to_str().unwrap()), + account_id + ); + assert_eq!( + request.url().query_pairs().collect::>(), + account_id + .map(|id| ("account_id".into(), id.into())) + .into_iter() + .collect::>() + ); + } + } #[test] fn resolve_subscription_url_default() { diff --git a/rust/src/providers/codex/subscription.rs b/rust/src/providers/codex/subscription.rs index a5b3d63c609..b0c4025d5c3 100644 --- a/rust/src/providers/codex/subscription.rs +++ b/rust/src/providers/codex/subscription.rs @@ -52,7 +52,7 @@ async fn fetch_subscription_metadata( access_token: &str, account_id: Option<&str>, ) -> OpenAISubscriptionFetchResult { - let request = api + let mut request = api .authed_get( &format!("{}{}", base_url.trim_end_matches('/'), SUBSCRIPTION_PATH), access_token, @@ -61,6 +61,10 @@ async fn fetch_subscription_metadata( .header("Cache-Control", "no-cache, no-store, max-age=0") .header("Pragma", "no-cache") .timeout(Duration::from_secs(8)); + // 订阅接口同时要求账号查询参数;由 reqwest 编码,保持与请求头的账号一致。 + if let Some(account_id) = account_id.filter(|id| !id.is_empty()) { + request = request.query(&[("account_id", account_id)]); + } let response = match request.send().await { Ok(response) => response, Err(error) => { @@ -94,3 +98,56 @@ pub(super) fn is_subscription_authority(base_url: &str) -> bool { && url.fragment().is_none() }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn subscription_account_query_returns_live_renewal_and_replaces_old_period() { + // 复现 184 的响应形状:查询参数必填,自动续费日优先于旧登录周期。 + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/subscriptions") + .match_query(mockito::Matcher::UrlEncoded( + "account_id".into(), + "account-test+id&x=1/?".into(), + )) + .match_header("chatgpt-account-id", "account-test+id&x=1/?") + .match_header("authorization", "Bearer synthetic-token") + .with_status(200) + .with_body(r#"{"active_until":"2026-12-01T04:55:35Z","will_renew":true}"#) + .create_async() + .await; + let api = CodexApi { + client: reqwest::Client::builder().no_proxy().build().unwrap(), + home_dir: std::path::PathBuf::new(), + codex_home_override: None, + }; + let result = fetch_subscription_metadata( + &api, + &server.url(), + "synthetic-token", + Some("account-test+id&x=1/?"), + ) + .await; + let period = CredentialSubscriptionPeriod { + starts_at: None, + ends_at: Some("2026-10-01T03:17:52Z".parse().unwrap()), + recorded_at: None, + }; + let usage = apply_subscription_result( + UsageSnapshot::new(crate::core::RateWindow::new(10.0)), + result, + Some(&period), + ); + let subscription = usage.subscription.expect("实时订阅日期"); + assert_eq!( + subscription.renews_at.unwrap().to_rfc3339(), + "2026-12-01T04:55:35+00:00" + ); + assert!(subscription.expires_at.is_none()); + assert!(usage.credential_subscription_period.is_none()); + mock.assert_async().await; + } +} From cf516f27aa28c713895f1ead709032915530c482 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:55:04 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Grafana=E8=AE=A2=E9=98=85=E8=A1=A8?= =?UTF-8?q?=E4=BB=85=E4=BF=9D=E7=95=99=E8=8A=82=E7=82=B9=E5=92=8C=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/grafana/codexbar-codex-zh.json | 54 +++-------------------------- docs/grafana/test_queries.py | 15 ++++---- docs/prometheus/README.zh-CN.md | 8 ++--- 3 files changed, 17 insertions(+), 60 deletions(-) diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json index 8fe4553c993..4976af8ffbb 100644 --- a/docs/grafana/codexbar-codex-zh.json +++ b/docs/grafana/codexbar-codex-zh.json @@ -978,7 +978,7 @@ "type": "prometheus", "uid": "${datasource}" }, - "description": "按节点展示订阅开始和结束时间。结束时间优先采用订阅接口明确提供的到期时间,自动续费账号使用下一次续费时间,接口不可用时采用登录记录中的周期结束时间。登录记录可能滞后,续费时间和周期结束不代表账号届时停用。时间沿用仪表盘时区,默认浏览器本地时区;缺失时显示“未提供”。", + "description": "按节点展示订阅结束时间。优先采用订阅接口明确提供的到期时间,自动续费账号使用下一次续费时间,接口不可用时采用登录记录中的周期结束时间。登录记录可能滞后,续费时间和周期结束不代表账号届时停用。时间沿用仪表盘时区,默认浏览器本地时区;缺失时显示“未提供”。", "fieldConfig": { "defaults": { "custom": { @@ -990,32 +990,6 @@ } }, "overrides": [ - { - "matcher": { - "id": "byName", - "options": "订阅开始时间" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsIso" - }, - { - "id": "mappings", - "value": [ - { - "options": { - "0": { - "color": "orange", - "text": "未提供" - } - }, - "type": "value" - } - ] - } - ] - }, { "matcher": { "id": "byName", @@ -1064,15 +1038,6 @@ "showHeader": true }, "targets": [ - { - "editorMode": "code", - "expr": "(max by (instance) (codexbar_subscription_start_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", - "format": "table", - "instant": true, - "legendFormat": "订阅开始时间", - "range": false, - "refId": "A" - }, { "editorMode": "code", "expr": "((max by (instance) (codexbar_subscription_expiry_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_renewal_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) or on(instance) max by (instance) (codexbar_subscription_period_end_timestamp_seconds{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"})) * 1000) or on(instance) (max by (instance) (codexbar_provider_up{job=~\"${job}\",instance=~\"${instance}\",provider=\"codex\"}) * 0)", @@ -1080,25 +1045,16 @@ "instant": true, "legendFormat": "订阅结束时间", "range": false, - "refId": "B" + "refId": "A" } ], "title": "Codex 订阅信息", "transformations": [ - { - "id": "joinByField", - "options": { - "byField": "instance", - "mode": "outer" - } - }, { "id": "organize", "options": { "excludeByName": { "Time": true, - "Time #A": true, - "Time #B": true, "__name__": true, "job": true, "monitor": true, @@ -1106,13 +1062,11 @@ }, "indexByName": { "instance": 0, - "Value #A": 1, - "Value #B": 2 + "Value": 1 }, "renameByName": { "instance": "节点", - "Value #A": "订阅开始时间", - "Value #B": "订阅结束时间" + "Value": "订阅结束时间" } } } diff --git a/docs/grafana/test_queries.py b/docs/grafana/test_queries.py index 2084b31d66a..0d2cc378c8a 100644 --- a/docs/grafana/test_queries.py +++ b/docs/grafana/test_queries.py @@ -78,19 +78,22 @@ def test_explicit_regex_formatter_reproduces_reported_failure(self): with self.assertRaises(json.JSONDecodeError): json.loads(expanded[len('up{instance=~'):-1]) - def test_subscription_table_only_shows_start_and_end(self): - # 展示保持开始和结束两列,优先采用接口到期、续费日期,登录周期备用。 + def test_subscription_table_only_shows_node_and_end(self): + # 单查询直接整理节点和结束时间,不再连接开始时间或保留其列映射。 panel = next(panel for panel in self.dashboard["panels"] if panel.get("title") == "Codex 订阅信息") - self.assertEqual([target["refId"] for target in panel["targets"]], ["A", "B"]) - end_query = panel["targets"][1]["expr"] + self.assertEqual([target["refId"] for target in panel["targets"]], ["A"]) + end_query = panel["targets"][0]["expr"] self.assertLess(end_query.index("codexbar_subscription_expiry_timestamp_seconds"), end_query.index("codexbar_subscription_renewal_timestamp_seconds")) self.assertLess(end_query.index("codexbar_subscription_renewal_timestamp_seconds"), end_query.index("codexbar_subscription_period_end_timestamp_seconds")) self.assertIn("or on(instance)", end_query) self.assertNotIn("dates_from_id_token", end_query) - names = panel["transformations"][1]["options"]["renameByName"] - self.assertEqual(names, {"instance": "节点", "Value #A": "订阅开始时间", "Value #B": "订阅结束时间"}) + self.assertEqual([step["id"] for step in panel["transformations"]], ["organize"]) + names = panel["transformations"][0]["options"]["renameByName"] + self.assertEqual(names, {"instance": "节点", "Value": "订阅结束时间"}) + self.assertNotIn("订阅开始时间", json.dumps(panel, ensure_ascii=False)) + self.assertNotIn("codexbar_subscription_start_timestamp_seconds", end_query) self.assertIn("登录记录可能滞后", panel["description"]) diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md index 91a46f13dc1..e4b54862896 100644 --- a/docs/prometheus/README.zh-CN.md +++ b/docs/prometheus/README.zh-CN.md @@ -19,9 +19,9 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I `codexbar_reset_credits_available{provider="codex"}` 中,`0` 为已用完,`-1` 为 PAT 模式或接口不可用。`codexbar_reset_credits_next_expiry_timestamp_seconds` 只在有可用卡且存在未来有效期时输出,不暴露卡 ID。重置卡接口查询成功或失败均缓存 10 分钟;异常的周额度重置确认会获取独立观察。配额指标以 0 到 1 的比例表示,Grafana 面板会按百分比显示。成本来自本地日志估算,不是订阅账单。 -## 订阅开始和到期时间 +## 订阅结束时间 -“Codex 订阅信息”表仅显示“节点、订阅开始时间、订阅结束时间”三列,独立于额度窗口筛选。结束时间按“接口到期时间 → 接口续费时间 → 登录记录中的周期结束时间”取值;自动续费账号显示下一次续费日期,不表示届时停用。登录记录可能滞后,具体含义在表格说明中提示。指标仍分别保留以下字段,供需要详细信息的查询使用: +“Codex 订阅信息”表仅显示“节点、订阅结束时间”两列,独立于额度窗口筛选。结束时间按“接口到期时间 → 接口续费时间 → 登录记录中的周期结束时间”取值;自动续费账号显示下一次续费日期,不表示届时停用。登录记录可能滞后,具体含义在表格说明中提示。开始时间不再由面板查询或显示,指标仍分别保留以下字段,供需要详细信息的查询使用: | 订阅字段 | Prometheus 指标 | 含义 | | --- | --- | --- | @@ -36,11 +36,11 @@ curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_I 需开启设置中的“OpenAI 网页附加功能”(默认开启)。订阅查询使用现有 OAuth 凭据,在 `/backend-api/subscriptions` 同时传入 URL 编码后的 `account_id` 查询参数与相同的 `ChatGPT-Account-Id` 请求头;CLI 和托管账号路径一致。仅传请求头可能返回 HTTP 400。查询保留 8 秒超时及失败降级,不新增抓取请求或凭据刷新。 -订阅接口成功时始终优先,包括明确返回空订阅的情况。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充 `expiresAt`;面板会使用独立的续费指标,避免继续展示旧登录周期。接口未提供开始时间时,面板显示“未提供”,不从下一次续费日倒推。 +订阅接口成功时始终优先,包括明确返回空订阅的情况。自动续费开启时,`active_until` 表示下一次续费时间 `renewsAt`,不会冒充 `expiresAt`;面板会使用独立的续费指标,避免继续展示旧登录周期。 当订阅接口不可用时,OAuth 用量采集成功后可使用同一 `auth.json` 的 `id_token` 中明确记录的周期:`chatgpt_subscription_active_start` 作为开始时间,`chatgpt_subscription_active_until` 作为单独的“周期结束时间”。只有令牌声明的账号 ID 与当前凭据账号 ID 一致才接受;身份缺失、不匹配、日期格式错误或周期倒置时均忽略。PAT、API Key 和自定义后端不使用此降级来源。 -登录记录只反映令牌签发时的订阅周期,可能滞后,不能据此确认自动续费、取消订阅或当前订阅状态。来源和记录时间仍可通过上述指标查询;面板只显示开始和结束时间。旧周期结束日期仍保留,不能仅凭它告警“账号已到期”。不读取或更改刷新令牌、不触发登录,不增加网络请求。新的周期信息在 Codex 自己更新凭据后随下一次正常采集读取。 +登录记录只反映令牌签发时的订阅周期,可能滞后,不能据此确认自动续费、取消订阅或当前订阅状态。来源和记录时间仍可通过上述指标查询;面板只显示节点和结束时间。旧周期结束日期仍保留,不能仅凭它告警“账号已到期”。不读取或更改刷新令牌、不触发登录,不增加网络请求。新的周期信息在 Codex 自己更新凭据后随下一次正常采集读取。 登录周期仅通过 `/metrics` 导出,不写入持久化或通用 `subscription.expiresAt` 字段,也不进入桌面订阅到期提示。未取得日期时省略对应样本,表格显示“未提供”;这不代表账号已过期。不要根据额度重置、重置卡有效期、JWT `exp` 或续费时间推算订阅到期时间。 From 2d74472cf046a7fc5320a98b21ff0bce8e782a79 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:15:17 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E7=BB=AD?= =?UTF-8?q?=E8=B4=B9=E6=8C=87=E6=A0=87=E4=B8=8E=E8=AE=A2=E9=98=85=E8=A1=A8?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLI.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CLI.md b/docs/CLI.md index 354e3f47293..d0cfb0d8a67 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -323,6 +323,7 @@ What the metrics export: - Local Codex cost estimates, when available. - Codex OAuth reset credits as `codexbar_reset_credits_available{provider="codex"}` (`0` means used up, `-1` means unavailable or a PAT), and `codexbar_reset_credits_next_expiry_timestamp_seconds{provider="codex"}` when present. Reset-credit results, good or unavailable, are cached for ten minutes. - Codex 订阅开始和到期时间:`codexbar_subscription_start_timestamp_seconds{provider="codex"}` 对应 `startsAt`,`codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` 对应 `expiresAt`,均为 Unix 秒时间戳。只导出现有采集结果中明确提供的日期;到期时间即使已过仍保留,未提供则省略样本,不以续费或额度重置时间代替。 +- 自动续费日期独立导出为 `codexbar_subscription_renewal_timestamp_seconds{provider="codex"}`,对应 `renewsAt`,不表示届时停用。Grafana 订阅表只显示节点和结束时间,依次采用接口到期日期、接口续费日期、登录记录中的周期结束日期。 - 订阅接口不可用时,同账号 `id_token` 中明确记录的周期开始可作为开始时间的备用来源;周期结束独立输出为 `codexbar_subscription_period_end_timestamp_seconds`,不填入 `expiresAt`。`codexbar_subscription_dates_from_id_token` 的 `1` 表示登录记录、`0` 表示订阅接口;`codexbar_subscription_dates_recorded_timestamp_seconds` 标记登录记录时间。登录记录可能滞后,不是当前续费或取消状态;仅在 OAuth 用量成功、身份匹配、网页附加功能开启且使用标准 ChatGPT 后端时使用,不额外请求上游、不改动凭据。 The metrics leave out unknown, informational, non-finite and dynamic additional-limit values. They never guess a value or use a sentinel. They don't expose account identity, display labels, source names, free-form provider errors or version strings. Alert on provider health, snapshot age and quota values together. From 05e1ba98bbffc4faf55efa9d2a75d880b72c6395 Mon Sep 17 00:00:00 2001 From: le-shi <22446162+le-shi@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:43 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E5=B0=86=E8=AE=A2=E9=98=85=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=8A=95=E5=BD=B1=E7=A7=BB=E5=85=A5=20Codex=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLI.md | 3 - rust/src/cli/serve/metrics/snapshot.rs | 67 +++++------ rust/src/cli/serve/metrics/tests.rs | 106 +++++++++++++----- rust/src/core/mod.rs | 2 + rust/src/core/subscription_observation.rs | 51 +++++++++ rust/src/core/usage_snapshot.rs | 6 +- .../codex/api/subscription_period_tests.rs | 38 ++++++- rust/src/providers/codex/subscription.rs | 56 +++++++-- 8 files changed, 237 insertions(+), 92 deletions(-) create mode 100644 rust/src/core/subscription_observation.rs diff --git a/docs/CLI.md b/docs/CLI.md index d0cfb0d8a67..251f16a1d1f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -322,9 +322,6 @@ What the metrics export: - Quota values, for Codex only, in fixed `session`, `weekly`, `monthly` and `code_review` metric families. Used and remaining values are ratios from `0` to `1`, with no dynamic window label. - Local Codex cost estimates, when available. - Codex OAuth reset credits as `codexbar_reset_credits_available{provider="codex"}` (`0` means used up, `-1` means unavailable or a PAT), and `codexbar_reset_credits_next_expiry_timestamp_seconds{provider="codex"}` when present. Reset-credit results, good or unavailable, are cached for ten minutes. -- Codex 订阅开始和到期时间:`codexbar_subscription_start_timestamp_seconds{provider="codex"}` 对应 `startsAt`,`codexbar_subscription_expiry_timestamp_seconds{provider="codex"}` 对应 `expiresAt`,均为 Unix 秒时间戳。只导出现有采集结果中明确提供的日期;到期时间即使已过仍保留,未提供则省略样本,不以续费或额度重置时间代替。 -- 自动续费日期独立导出为 `codexbar_subscription_renewal_timestamp_seconds{provider="codex"}`,对应 `renewsAt`,不表示届时停用。Grafana 订阅表只显示节点和结束时间,依次采用接口到期日期、接口续费日期、登录记录中的周期结束日期。 -- 订阅接口不可用时,同账号 `id_token` 中明确记录的周期开始可作为开始时间的备用来源;周期结束独立输出为 `codexbar_subscription_period_end_timestamp_seconds`,不填入 `expiresAt`。`codexbar_subscription_dates_from_id_token` 的 `1` 表示登录记录、`0` 表示订阅接口;`codexbar_subscription_dates_recorded_timestamp_seconds` 标记登录记录时间。登录记录可能滞后,不是当前续费或取消状态;仅在 OAuth 用量成功、身份匹配、网页附加功能开启且使用标准 ChatGPT 后端时使用,不额外请求上游、不改动凭据。 The metrics leave out unknown, informational, non-finite and dynamic additional-limit values. They never guess a value or use a sentinel. They don't expose account identity, display labels, source names, free-form provider errors or version strings. Alert on provider health, snapshot age and quota values together. diff --git a/rust/src/cli/serve/metrics/snapshot.rs b/rust/src/cli/serve/metrics/snapshot.rs index dc40e119f47..43f20744ae8 100644 --- a/rust/src/cli/serve/metrics/snapshot.rs +++ b/rust/src/cli/serve/metrics/snapshot.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; use crate::cli::serve::collection::SnapshotCollection; -use crate::core::{ProviderId, RateWindow, RateWindowCadence}; +use crate::core::{ProviderId, RateWindow, RateWindowCadence, SubscriptionDateSource}; #[derive(Debug, Clone)] pub(crate) struct MetricsSnapshot { @@ -104,46 +104,31 @@ impl MetricsSnapshot { .filter(|credits| credits.available_count > 0) .and_then(|credits| credits.next_expires_at) .filter(|expiry| *expiry > input.generated_at), - // 只使用 Codex 明确提供的订阅日期,不以续费或额度重置时间代替。 - subscription_start: (id == ProviderId::Codex) - .then_some(result.usage.subscription.as_ref()) - .flatten() - .and_then(|subscription| subscription.starts_at) - .or_else(|| { - (id == ProviderId::Codex && result.usage.subscription.is_none()) - .then_some(result.usage.credential_subscription_period) - .flatten() - .and_then(|period| period.starts_at) - }), - subscription_expiry: (id == ProviderId::Codex) - .then_some(result.usage.subscription.as_ref()) - .flatten() - .and_then(|subscription| subscription.expires_at), - // 自动续费日单独投影,保留 expiresAt 的取消续费到期语义。 - subscription_renewal: (id == ProviderId::Codex) - .then_some(result.usage.subscription.as_ref()) - .flatten() - .and_then(|subscription| subscription.renews_at), - // 周期终点独立导出,不能冒充取消续费后的订阅到期时间。 - subscription_period_end: (id == ProviderId::Codex - && result.usage.subscription.is_none()) - .then_some(result.usage.credential_subscription_period) - .flatten() - .and_then(|period| period.ends_at), - subscription_recorded_at: (id == ProviderId::Codex - && result.usage.subscription.is_none()) - .then_some(result.usage.credential_subscription_period) - .flatten() - .and_then(|period| period.recorded_at), - subscription_from_id_token: (id == ProviderId::Codex) - .then(|| { - if result.usage.subscription.is_some() { - Some(false) - } else { - result.usage.credential_subscription_period.map(|_| true) - } - }) - .flatten(), + // 提供方已决定导出资格和来源;共享层只映射通用观测字段。 + subscription_start: result + .usage + .subscription_observation + .and_then(|dates| dates.starts_at), + subscription_expiry: result + .usage + .subscription_observation + .and_then(|dates| dates.expires_at), + subscription_renewal: result + .usage + .subscription_observation + .and_then(|dates| dates.renews_at), + subscription_period_end: result + .usage + .subscription_observation + .and_then(|dates| dates.period_end), + subscription_recorded_at: result + .usage + .subscription_observation + .and_then(|dates| dates.recorded_at), + subscription_from_id_token: result + .usage + .subscription_observation + .map(|dates| dates.source == SubscriptionDateSource::CredentialRecord), cost_today_usd, cost_last_30_days_usd, }, diff --git a/rust/src/cli/serve/metrics/tests.rs b/rust/src/cli/serve/metrics/tests.rs index ee980e1a2cf..ab27ec5c66a 100644 --- a/rust/src/cli/serve/metrics/tests.rs +++ b/rust/src/cli/serve/metrics/tests.rs @@ -15,6 +15,7 @@ use crate::core::{ ResetCreditsObservation, SubscriptionMetadata, UsageSnapshot, }; use crate::providers::codex::CodexApi; +use crate::providers::openai::OpenAISubscriptionFetchResult; fn at(hour: u32) -> chrono::DateTime { Utc.with_ymd_and_hms(2026, 9, 14, hour, 0, 0) @@ -204,12 +205,43 @@ fn renders_enabled_provider_health_and_codex_metrics_without_private_text() { } } +#[test] +fn subscription_dates_require_explicit_provider_observation() { + // 共享指标层不能按提供方名称推断导出资格,普通订阅元数据不会自动变成指标。 + let subscribed = usage(RateWindow::new(10.0)).with_subscription(Some( + SubscriptionMetadata::new(Some(at(1)), Some(at(3)), Some(at(4))), + )); + let snapshot = metrics_snapshot( + vec![ + provider( + "codex", + Ok(ProviderFetchResult::new(subscribed.clone(), "oauth")), + ), + provider("claude", Ok(ProviderFetchResult::new(subscribed, "oauth"))), + ], + HashMap::new(), + ); + let body = render_at(&snapshot, at(5)).unwrap(); + assert!( + !body + .lines() + .any(|line| line.starts_with("codexbar_subscription_")), + "订阅指标必须由提供方显式准备的观测数据驱动" + ); +} + #[test] fn subscription_dates_export_explicit_start_and_expiry_even_after_expiration() { // 已到期时间仍需保留,监控才能识别过期,而不能把它误判为缺失。 - let codex_usage = usage(RateWindow::new(10.0)).with_subscription(Some( - SubscriptionMetadata::new(Some(at(1)), Some(at(3)), None), - )); + let codex_usage = CodexApi::apply_subscription_result_for_test( + usage(RateWindow::new(10.0)), + OpenAISubscriptionFetchResult::Success(Some(SubscriptionMetadata::new( + Some(at(1)), + Some(at(3)), + None, + ))), + None, + ); let snapshot = metrics_snapshot( vec![provider( "codex", @@ -232,9 +264,7 @@ fn subscription_dates_export_explicit_start_and_expiry_even_after_expiration() { #[test] fn subscription_renewal_exports_live_date_without_old_period_or_false_expiry() { - use crate::providers::openai::{ - OpenAISubscriptionFetchResult, parse_subscription_http_response, - }; + use crate::providers::openai::parse_subscription_http_response; // 184 的实时 active_until 表示下一次续费,旧 id_token 的 10 月周期不能继续展示。 let OpenAISubscriptionFetchResult::Success(metadata) = parse_subscription_http_response( @@ -243,20 +273,22 @@ fn subscription_renewal_exports_live_date_without_old_period_or_false_expiry() { ) else { panic!("有效的订阅响应"); }; - let mut codex_usage = usage(RateWindow::new(10.0)).with_subscription(metadata); - codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + let unobserved = usage(RateWindow::new(10.0)).with_subscription(metadata); + let period = crate::core::CredentialSubscriptionPeriod { starts_at: None, ends_at: Some("2026-10-01T03:17:52Z".parse().unwrap()), recorded_at: None, - }); + }; + let codex_usage = CodexApi::apply_subscription_result_for_test( + unobserved.clone(), + OpenAISubscriptionFetchResult::Unavailable, + Some(&period), + ); let body = render_at( &metrics_snapshot( vec![ - provider( - "codex", - Ok(ProviderFetchResult::new(codex_usage.clone(), "oauth")), - ), - provider("claude", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + provider("codex", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + provider("claude", Ok(ProviderFetchResult::new(unobserved, "oauth"))), ], HashMap::new(), ), @@ -290,8 +322,11 @@ fn subscription_dates_do_not_infer_expiry_from_renewal_or_quota_reset() { None, Some(SubscriptionMetadata::new(None, None, Some(at(4)))), ] { - let codex_usage = usage(RateWindow::with_details(10.0, Some(300), Some(at(6)), None)) - .with_subscription(subscription); + let codex_usage = CodexApi::apply_subscription_result_for_test( + usage(RateWindow::with_details(10.0, Some(300), Some(at(6)), None)), + OpenAISubscriptionFetchResult::Success(subscription), + None, + ); let body = render_at( &metrics_snapshot( vec![provider( @@ -320,12 +355,14 @@ fn subscription_dates_keep_independent_fields_and_codex_provider_scope() { for (start, expiry) in [(Some(at(1)), None), (None, Some(at(3)))] { let subscribed = usage(RateWindow::new(10.0)) .with_subscription(Some(SubscriptionMetadata::new(start, expiry, None))); + let codex_usage = CodexApi::apply_subscription_result_for_test( + subscribed.clone(), + OpenAISubscriptionFetchResult::Unavailable, + None, + ); let snapshot = metrics_snapshot( vec![ - provider( - "codex", - Ok(ProviderFetchResult::new(subscribed.clone(), "oauth")), - ), + provider("codex", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), provider("claude", Ok(ProviderFetchResult::new(subscribed, "oauth"))), ], HashMap::new(), @@ -371,20 +408,22 @@ fn subscription_dates_are_omitted_when_provider_fetch_fails() { #[test] fn subscription_period_exports_login_record_without_claiming_subscription_expiry() { // 登录记录是周期信息,不能生成 expiresAt 或输出账号、令牌。 - let mut codex_usage = usage(RateWindow::new(10.0)); - codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + let unobserved = usage(RateWindow::new(10.0)); + let period = crate::core::CredentialSubscriptionPeriod { starts_at: Some(at(1)), ends_at: Some(at(3)), recorded_at: Some(at(0)), - }); + }; + let codex_usage = CodexApi::apply_subscription_result_for_test( + unobserved.clone(), + OpenAISubscriptionFetchResult::Unavailable, + Some(&period), + ); let body = render_at( &metrics_snapshot( vec![ - provider( - "codex", - Ok(ProviderFetchResult::new(codex_usage.clone(), "oauth")), - ), - provider("claude", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + provider("codex", Ok(ProviderFetchResult::new(codex_usage, "oauth"))), + provider("claude", Ok(ProviderFetchResult::new(unobserved, "oauth"))), ], HashMap::new(), ), @@ -420,14 +459,19 @@ fn subscription_period_exports_login_record_without_claiming_subscription_expiry #[test] fn subscription_period_api_dates_take_priority_over_login_records() { - let mut codex_usage = usage(RateWindow::new(10.0)).with_subscription(Some( + let subscribed = usage(RateWindow::new(10.0)).with_subscription(Some( SubscriptionMetadata::new(Some(at(2)), Some(at(4)), None), )); - codex_usage.credential_subscription_period = Some(crate::core::CredentialSubscriptionPeriod { + let period = crate::core::CredentialSubscriptionPeriod { starts_at: Some(at(1)), ends_at: Some(at(3)), recorded_at: None, - }); + }; + let codex_usage = CodexApi::apply_subscription_result_for_test( + subscribed, + OpenAISubscriptionFetchResult::Unavailable, + Some(&period), + ); let body = render_at( &metrics_snapshot( vec![provider( diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 556976e96cd..021837ff033 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -35,6 +35,7 @@ mod redactor; mod refresh_interaction; mod session_equivalent_forecast; mod sqlite; +mod subscription_observation; mod throughput; mod timezone; mod token_accounts; @@ -80,6 +81,7 @@ pub use redactor::*; pub use refresh_interaction::*; pub use session_equivalent_forecast::*; pub use sqlite::*; +pub(crate) use subscription_observation::{SubscriptionDateSource, SubscriptionObservation}; pub use throughput::*; pub use timezone::{local_timezone_name, try_local_timezone_name}; pub use token_accounts::*; diff --git a/rust/src/core/subscription_observation.rs b/rust/src/core/subscription_observation.rs new file mode 100644 index 00000000000..f25afd2c276 --- /dev/null +++ b/rust/src/core/subscription_observation.rs @@ -0,0 +1,51 @@ +//! 提供方整理后的订阅观测数据,共享监控层只读取,不选择日期来源。 + +use chrono::{DateTime, Utc}; + +/// 日期来源用于区分实时接口与历史记录,不携带提供方或账号身份。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SubscriptionDateSource { + ProviderApi, + CredentialRecord, +} + +/// 由提供方显式填充的临时观测;历史周期终点与确认到期日期分别保存。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SubscriptionObservation { + pub starts_at: Option>, + pub expires_at: Option>, + pub renews_at: Option>, + pub period_end: Option>, + pub recorded_at: Option>, + pub source: SubscriptionDateSource, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{RateWindow, UsageSnapshot}; + + #[test] + fn subscription_observation_stays_out_of_serialized_snapshots() { + // 临时观测不能经持久化或桌面桥接流出,也不能从外部 JSON 注入。 + let mut usage = UsageSnapshot::new(RateWindow::new(10.0)); + usage.updated_at = "2026-01-01T00:00:00Z".parse().unwrap(); + let at = "2026-11-06T13:04:47Z".parse().unwrap(); + usage.subscription_observation = Some(SubscriptionObservation { + starts_at: None, + expires_at: None, + renews_at: None, + period_end: Some(at), + recorded_at: None, + source: SubscriptionDateSource::CredentialRecord, + }); + let mut json = serde_json::to_value(&usage).unwrap(); + assert!(json.get("subscription_observation").is_none()); + assert!(!json.to_string().contains("2026-11-06")); + json["subscription_observation"] = serde_json::json!({ + "expires_at": "2026-11-06T13:04:47Z", "source": "ProviderApi" + }); + let restored: UsageSnapshot = serde_json::from_value(json).unwrap(); + assert!(restored.subscription_observation.is_none()); + } +} diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 36f22472771..cea98c3217f 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -318,9 +318,9 @@ pub struct UsageSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub subscription: Option, - /// 临时监控数据,不进入持久化或桌面桥接;与确认的订阅到期日期分别建模。 + /// 提供方显式准备的临时观测,不进入持久化或桌面桥接。 #[serde(skip)] - pub credential_subscription_period: Option, + pub(crate) subscription_observation: Option, } impl UsageSnapshot { @@ -341,7 +341,7 @@ impl UsageSnapshot { account_organization: None, login_method: None, subscription: None, - credential_subscription_period: None, + subscription_observation: None, } } diff --git a/rust/src/providers/codex/api/subscription_period_tests.rs b/rust/src/providers/codex/api/subscription_period_tests.rs index d2b4453a7d0..4cbbc7fba41 100644 --- a/rust/src/providers/codex/api/subscription_period_tests.rs +++ b/rust/src/providers/codex/api/subscription_period_tests.rs @@ -38,16 +38,27 @@ fn subscription_period_reads_explicit_id_token_dates_without_inventing_expiry() ); assert_eq!(period.recorded_at.unwrap().timestamp(), 1_790_000_000); - let usage = UsageSnapshot::new(crate::core::RateWindow::new(12.0)); + // 固定采集时间,避免运行日恰好等于订阅日期时产生序列化误报。 + let mut usage = UsageSnapshot::new(crate::core::RateWindow::new(12.0)); + usage.updated_at = "2026-01-01T00:00:00Z".parse().unwrap(); let usage = subscription::apply_subscription_result( usage, crate::providers::openai::OpenAISubscriptionFetchResult::Unavailable, Some(&period), ); assert!(usage.subscription.is_none()); - assert!(usage.credential_subscription_period.is_some()); + let observation = usage.subscription_observation.unwrap(); + assert_eq!(observation.starts_at, period.starts_at); + assert_eq!(observation.period_end, period.ends_at); + assert_eq!(observation.recorded_at, period.recorded_at); + assert!(observation.expires_at.is_none()); + assert!(observation.renews_at.is_none()); + assert_eq!( + observation.source, + crate::core::SubscriptionDateSource::CredentialRecord + ); let serialized = serde_json::to_string(&usage).unwrap(); - assert!(!serialized.contains("credential_subscription_period")); + assert!(!serialized.contains("subscription_observation")); assert!(!serialized.contains("2026-11-06")); } @@ -94,12 +105,29 @@ fn subscription_period_successful_api_response_suppresses_credential_fallback() }; for metadata in [None, Some(SubscriptionMetadata::new(None, None, Some(at)))] { // 明确的空订阅结果必须压过旧登录记录,防止已取消订阅被重新填回。 - let usage = subscription::apply_subscription_result( + let stale = subscription::apply_subscription_result( UsageSnapshot::new(RateWindow::new(1.0)), + OpenAISubscriptionFetchResult::Unavailable, + Some(&period), + ); + let usage = subscription::apply_subscription_result( + stale, OpenAISubscriptionFetchResult::Success(metadata.clone()), Some(&period), ); assert_eq!(usage.subscription, metadata); - assert!(usage.credential_subscription_period.is_none()); + match (usage.subscription_observation, metadata) { + (Some(observation), Some(metadata)) => { + assert_eq!(observation.renews_at, metadata.renews_at); + assert!(observation.period_end.is_none()); + assert!(observation.recorded_at.is_none()); + assert_eq!( + observation.source, + crate::core::SubscriptionDateSource::ProviderApi + ); + } + (None, None) => {} + _ => panic!("实时结果必须完整替换历史观测,包括明确的空结果"), + } } } diff --git a/rust/src/providers/codex/subscription.rs b/rust/src/providers/codex/subscription.rs index b0c4025d5c3..e63da10bcfd 100644 --- a/rust/src/providers/codex/subscription.rs +++ b/rust/src/providers/codex/subscription.rs @@ -1,6 +1,8 @@ use std::time::Duration; -use crate::core::{CredentialSubscriptionPeriod, UsageSnapshot}; +use crate::core::{ + CredentialSubscriptionPeriod, SubscriptionDateSource, SubscriptionObservation, UsageSnapshot, +}; use crate::providers::openai::{OpenAISubscriptionFetchResult, parse_subscription_http_response}; use super::CodexApi; @@ -35,14 +37,47 @@ pub(super) fn apply_subscription_result( result: OpenAISubscriptionFetchResult, credential_period: Option<&CredentialSubscriptionPeriod>, ) -> UsageSnapshot { - // 成功响应(包括明确的空结果)始终优先;仅不可用时挂载独立的登录周期记录。 - usage.credential_subscription_period = None; - match result { - OpenAISubscriptionFetchResult::Success(metadata) => usage.with_subscription(metadata), - OpenAISubscriptionFetchResult::Unavailable => { - usage.credential_subscription_period = credential_period.copied(); - usage + // 成功空结果也禁止回退;日期来源与优先级只在 Codex 层决定。 + let fallback = match result { + OpenAISubscriptionFetchResult::Success(metadata) => { + usage = usage.with_subscription(metadata); + None } + OpenAISubscriptionFetchResult::Unavailable => credential_period, + }; + usage.subscription_observation = usage + .subscription + .as_ref() + .map(|metadata| SubscriptionObservation { + starts_at: metadata.starts_at, + expires_at: metadata.expires_at, + renews_at: metadata.renews_at, + period_end: None, + recorded_at: None, + source: SubscriptionDateSource::ProviderApi, + }) + .or_else(|| { + fallback.map(|period| SubscriptionObservation { + starts_at: period.starts_at, + expires_at: None, + renews_at: None, + period_end: period.ends_at, + recorded_at: period.recorded_at, + source: SubscriptionDateSource::CredentialRecord, + }) + }); + usage +} + +#[cfg(test)] +impl CodexApi { + /// 让指标回归用例经过真实的提供方整理逻辑,不手工伪造投影结果。 + pub(crate) fn apply_subscription_result_for_test( + usage: UsageSnapshot, + result: OpenAISubscriptionFetchResult, + credential_period: Option<&CredentialSubscriptionPeriod>, + ) -> UsageSnapshot { + apply_subscription_result(usage, result, credential_period) } } @@ -147,7 +182,10 @@ mod tests { "2026-12-01T04:55:35+00:00" ); assert!(subscription.expires_at.is_none()); - assert!(usage.credential_subscription_period.is_none()); + let observation = usage.subscription_observation.unwrap(); + assert_eq!(observation.renews_at, subscription.renews_at); + assert!(observation.period_end.is_none()); + assert_eq!(observation.source, SubscriptionDateSource::ProviderApi); mock.assert_async().await; } }