diff --git a/README.md b/README.md index 7300fe895..4d8dffeab 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh 部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。 API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。 -页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch +页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟 默认地址只能在服务器本机访问,从其他设备使用时,需要配置 [HTTPS 反向代理](deploy/README.md#公网访问) diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 5e03cba68..b0ac21a79 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -346,7 +346,7 @@ pub async fn initialize( config.session_ttl_minutes, client_config.session_ttl_minutes, store.auth(), - client_key_verifier, + client_key_verifier.clone(), )); auth.ensure_default_admin(config.default_password.expose()) .await?; @@ -373,6 +373,7 @@ pub async fn initialize( let system = Arc::new(DefaultSystemService::new(system)); let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( auth.clone(), + client_key_verifier, store.client_keys(), store.observability(), system.clone(), diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index 74b6c12e1..0aa53668d 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -1,4 +1,4 @@ -//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。 +//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。 use std::sync::Arc; @@ -22,12 +22,21 @@ use crate::{ }, ports::store::{ClientKeyStore, ObservabilityStore}, }; -use gateway_core::policy::ClientApiKeyId; +use gateway_core::{ + engine::{ + budget::ClientBudgetStatus, + execution::{ClientAuthenticationError, ClientKeyVerifier}, + }, + policy::ClientApiKeyId, +}; use super::{map_store_error, observability::health_timeline_at}; #[async_trait] pub trait KeyUsageService: Send + Sync { + /// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。 + async fn budget(&self, plaintext: &str) -> Result, AdminError>; + async fn version(&self, session_id: Option<&str>) -> Result, AdminError>; async fn config(&self, session_id: Option<&str>) @@ -48,6 +57,7 @@ pub trait KeyUsageService: Send + Sync { pub(crate) struct DefaultKeyUsageService { auth: Arc, + verifier: Arc, keys: Arc, observations: Arc, system: Arc, @@ -56,12 +66,14 @@ pub(crate) struct DefaultKeyUsageService { impl DefaultKeyUsageService { pub(crate) fn new( auth: Arc, + verifier: Arc, keys: Arc, observations: Arc, system: Arc, ) -> Self { Self { auth, + verifier, keys, observations, system, @@ -95,6 +107,24 @@ fn usage_filter(id: &ClientApiKeyId, model: Option) -> UsageFilter { #[async_trait] impl KeyUsageService for DefaultKeyUsageService { + async fn budget(&self, plaintext: &str) -> Result, AdminError> { + let id = match self.verifier.verify_client_key(plaintext) { + Ok(id) => id, + Err(ClientAuthenticationError::InvalidKey) => return Ok(None), + Err(ClientAuthenticationError::SnapshotUnavailable) => { + return Err(AdminError::new( + AdminErrorKind::Unavailable, + "密钥验证暂时不可用", + )); + } + }; + self.keys + .get_client_key(&id) + .await + .map(|key| key.filter(|key| key.enabled).map(|key| key.budget)) + .map_err(|error| map_store_error(error, "key usage budget")) + } + async fn version(&self, session_id: Option<&str>) -> Result, AdminError> { if self.key_id(session_id).await?.is_none() { return Ok(None); diff --git a/backend/crates/gateway-api/src/openai/mod.rs b/backend/crates/gateway-api/src/openai/mod.rs index 638ce3c2b..8e529a56f 100644 --- a/backend/crates/gateway-api/src/openai/mod.rs +++ b/backend/crates/gateway-api/src/openai/mod.rs @@ -9,6 +9,7 @@ pub mod responses; pub mod router; pub mod search; pub(crate) mod service; +mod usage; /// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。 fn with_model_request_id( diff --git a/backend/crates/gateway-api/src/openai/router.rs b/backend/crates/gateway-api/src/openai/router.rs index 475eb03bd..32cf2ad64 100644 --- a/backend/crates/gateway-api/src/openai/router.rs +++ b/backend/crates/gateway-api/src/openai/router.rs @@ -11,6 +11,7 @@ use super::{ models::{model_detail, models}, responses::{responses, responses_websocket}, search::standalone_search, + usage, }; use crate::ApiState; @@ -25,6 +26,7 @@ pub(crate) fn router() -> Router { .route("/v1/models", get(models)) // 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。 .route("/v1/models/{model_id}", get(model_detail)) + .merge(usage::router()) // OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝 // 上游本可接受的未来 payload。 .layer(DefaultBodyLimit::disable()) diff --git a/backend/crates/gateway-api/src/openai/usage.rs b/backend/crates/gateway-api/src/openai/usage.rs new file mode 100644 index 000000000..ffcdb8ac9 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/usage.rs @@ -0,0 +1,90 @@ +//! Bearer Key 的只读额度查询,与自助页面共用当前预算账本。 + +use std::time::SystemTime; + +use axum::{ + Json, Router, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + middleware, + response::{IntoResponse, Response}, + routing::get, +}; +use chrono::{DateTime, Utc}; +use gateway_core::metering::Decimal; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ApiState, auth::SessionState as _}; + +use super::{ + auth::{bearer_client_api_key, client_access_error_response}, + error::{missing_client_api_key_response, openai_error_response}, +}; + +pub(super) fn router() -> Router { + Router::new() + .route("/v1/usage", get(usage)) + .layer(middleware::map_response(no_store)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UsageQuery {} + +async fn usage( + State(state): State, + headers: HeaderMap, + uri: axum::http::Uri, +) -> Response { + let key = match bearer_client_api_key(&headers) { + Ok(key) => key, + Err(error) => return client_access_error_response(error.into()), + }; + // 范围只能来自 Bearer 身份,不接受调用者指定 Key、账号或时间窗口。 + if Query::::try_from_uri(&uri).is_err() { + return openai_error_response( + StatusCode::BAD_REQUEST, + "Usage query does not accept query parameters", + "invalid_request_error", + "invalid_usage_query", + ) + .into_response(); + } + let budget = match state.admin_services().key_usage().budget(key).await { + Ok(Some(budget)) => budget, + Ok(None) => return missing_client_api_key_response().into_response(), + Err(_) => { + return openai_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Key usage is temporarily unavailable", + "server_error", + "usage_unavailable", + ) + .into_response(); + } + }; + Json(json!({ + "unit": "USD", + "daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at), + "weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at), + })) + .into_response() +} + +fn window(total: Decimal, used: Decimal, resets_at: Option) -> Value { + let limited = total != Decimal::ZERO; + json!({ + "total": limited.then(|| total.canonical()), + "used": used.canonical(), + "remaining": limited.then(|| total.saturating_sub(used).canonical()), + "resetsAt": resets_at.map(DateTime::::from), + }) +} + +async fn no_store(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 8eece02db..668b1266e 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -81,6 +81,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/openai/search/http.rs", "src/openai/search/mod.rs", "src/openai/service.rs", + "src/openai/usage.rs", "src/session_cookie.rs", ]; expected.sort_unstable(); @@ -132,6 +133,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/openai/responses/websocket/protocol.rs", "tests/openai/router.rs", "tests/openai/search/mod.rs", + "tests/openai/usage.rs", ]; expected.sort_unstable(); diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 7347a6aa9..63950002f 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -6,6 +6,7 @@ mod models; mod responses; mod router; mod search; +mod usage; use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU32; diff --git a/backend/crates/gateway-api/tests/openai/usage.rs b/backend/crates/gateway-api/tests/openai/usage.rs new file mode 100644 index 000000000..7faa10b1d --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/usage.rs @@ -0,0 +1,189 @@ +use axum::{ + Router, + http::{Method, StatusCode, header}, + response::Response, +}; +use chrono::{Duration, Utc}; +use gateway_core::{ + engine::budget::{ClientBudgetLimits, ClientBudgetStatus}, + policy::ClientApiKeyId, +}; +use serde_json::json; +use tower::ServiceExt as _; + +use crate::{ + admin::AdminTestFixture, + support::{RAW_KEY, empty_request, key_fixture, response_json}, +}; + +const KEY: &str = RAW_KEY; + +async fn fixture() -> (AdminTestFixture, Router) { + let fixture = key_fixture().await; + let id = ClientApiKeyId::new("key-42").unwrap(); + let mut key = fixture + .services + .client_keys() + .reveal(&id) + .await + .unwrap() + .record; + key.id = id; + key.label = Some("private-usage-sentinel".to_owned()); + key.budget = ClientBudgetStatus { + limits: ClientBudgetLimits { + daily_usd: "1".parse().unwrap(), + weekly_usd: "5".parse().unwrap(), + }, + daily_used_usd: "0.6400000001".parse().unwrap(), + weekly_used_usd: "2.35".parse().unwrap(), + daily_resets_at: Some((Utc::now() + Duration::days(1)).into()), + weekly_resets_at: Some((Utc::now() + Duration::days(7)).into()), + }; + *fixture.client_key.lock().unwrap() = Some(key); + let app = super::api_router_with_admin(fixture.services.clone()); + (fixture, app) +} + +async fn query(app: &Router, path: &str, authorization: Option<&str>) -> Response { + let mut request = empty_request(Method::GET, path); + if let Some(value) = authorization { + request + .headers_mut() + .insert(header::AUTHORIZATION, value.parse().unwrap()); + } + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + response +} + +#[tokio::test] +async fn usage_returns_current_key_budget_without_exposing_private_data_or_writing_usage() { + let (fixture, app) = fixture().await; + let before = fixture.client_key.lock().unwrap().clone().unwrap(); + let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!( + body, + json!({ + "unit": "USD", + "daily": { + "total": "1", "used": "0.6400000001", "remaining": "0.3599999999", + "resetsAt": before.budget.daily_resets_at.map(chrono::DateTime::::from), + }, + "weekly": { + "total": "5", "used": "2.35", "remaining": "2.65", + "resetsAt": before.budget.weekly_resets_at.map(chrono::DateTime::::from), + }, + }) + ); + assert!(!body.to_string().contains("private-usage-sentinel")); + assert_eq!(fixture.client_key.lock().unwrap().as_ref(), Some(&before)); + assert!(fixture.observations.lock().unwrap().summaries.is_empty()); +} + +#[tokio::test] +async fn usage_remains_readable_when_exhausted_and_never_returns_negative_remaining() { + let (fixture, app) = fixture().await; + { + let mut key = fixture.client_key.lock().unwrap(); + let budget = &mut key.as_mut().unwrap().budget; + budget.daily_used_usd = "1".parse().unwrap(); + budget.weekly_used_usd = "5.75".parse().unwrap(); + } + let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["daily"]["remaining"], "0"); + assert_eq!(body["weekly"]["remaining"], "0"); + assert_eq!(body["weekly"]["used"], "5.75"); +} + +#[tokio::test] +async fn usage_distinguishes_unlimited_and_unused_windows_from_exhausted_budgets() { + let (fixture, app) = fixture().await; + for (daily, weekly) in [("0", "0"), ("1", "0"), ("0", "5")] { + fixture.client_key.lock().unwrap().as_mut().unwrap().budget = ClientBudgetStatus { + limits: ClientBudgetLimits { + daily_usd: daily.parse().unwrap(), + weekly_usd: weekly.parse().unwrap(), + }, + ..Default::default() + }; + let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + for (period, limit) in [("daily", daily), ("weekly", weekly)] { + assert_eq!( + body[period], + json!({ + "total": (limit != "0").then_some(limit), + "used": "0", + "remaining": (limit != "0").then_some(limit), + "resetsAt": null, + }) + ); + } + } +} + +#[tokio::test] +async fn usage_rejects_missing_invalid_disabled_deleted_and_other_keys() { + let (fixture, app) = fixture().await; + for authorization in [None, Some("Basic invalid"), Some("Bearer unknown-key")] { + let response = query(&app, "/v1/usage", authorization).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response_json(response).await.get("error").is_some()); + } + let original = fixture.client_key.lock().unwrap().clone().unwrap(); + for mode in ["disabled", "deleted", "other"] { + { + let mut key = fixture.client_key.lock().unwrap(); + *key = Some(original.clone()); + match mode { + "disabled" => key.as_mut().unwrap().enabled = false, + "deleted" => *key = None, + _ => key.as_mut().unwrap().id = ClientApiKeyId::new("other-key").unwrap(), + } + } + let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{mode}"); + } +} + +#[tokio::test] +async fn usage_does_not_accept_sessions_admin_keys_or_caller_selected_scope() { + let (fixture, app) = fixture().await; + fixture.auth.insert_session("valid-admin"); + for (name, value) in [ + (header::COOKIE.as_str(), "cpr_session=valid-admin"), + ("x-api-key", KEY), + ] { + let mut request = empty_request(Method::GET, "/v1/usage"); + request.headers_mut().insert( + axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + for suffix in [ + "?keyId=other", + "?clientApiKeyRef=other", + "?startTime=2026-01-01", + "?api_key=test", + ] { + let response = query( + &app, + &format!("/v1/usage{suffix}"), + Some(&format!("Bearer {KEY}")), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(response).await["error"]["code"], + "invalid_usage_query" + ); + } +} diff --git a/backend/crates/gateway-core/src/metering/mod.rs b/backend/crates/gateway-core/src/metering/mod.rs index 97113da5d..e2beac041 100644 --- a/backend/crates/gateway-core/src/metering/mod.rs +++ b/backend/crates/gateway-core/src/metering/mod.rs @@ -39,6 +39,12 @@ impl Decimal { self.0 } + /// 非负金额相减,超支时返回零。 + #[must_use] + pub const fn saturating_sub(self, other: Self) -> Self { + Self(self.0.saturating_sub(other.0)) + } + #[must_use] pub fn checked_add(self, other: Self) -> Option { self.0 diff --git a/deploy/README.md b/deploy/README.md index d271477e3..53a90885e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -135,6 +135,8 @@ SSE 注释保活用于防止传输链路空闲断开,不会重置 Codex 等待 在管理端创建客户端密钥,打开「使用密钥」,按操作系统复制 `config.toml` 和 `auth.json`, 或通过 CCSwitch 导入。已有文件先备份,合并后完全退出并重启 Codex。 +CCSwitch 导入同时配置当前 Key 的日/周额度查询,当前 Provider 默认每 30 分钟刷新。 +查询地址和凭据随导入生成,在 CCSwitch 中修改 Provider 的地址或 Key 后,需重新导入以同步用量查询。 Linux/macOS 默认目录为 `~/.codex/`,Windows 为 `%USERPROFILE%\.codex\`; 设置过 `CODEX_HOME` 时以该目录为准。Provider 设置应写入用户配置,不要只写到项目目录。 diff --git a/docs/api.md b/docs/api.md index 970a9e525..234124dc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,7 +27,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` 表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;适用门禁的客户端没有合法版本,或版本 -低于对应门槛时,所有 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。 +低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。 未知客户端保持兼容,不应用版本门禁。 Desktop 应用版本优先取 `version` 头,未提供时取 User-Agent 中的 `(Codex Desktop; <版本>)`。 @@ -156,6 +156,7 @@ WebSocket message 和 frame 不设置网关私有长度上限;协议可接受 | `POST` | `/v1/images/edits` | 通过 OpenAI Provider 发起图像编辑;JSON 请求与响应正文原样转发 | | `GET` | `/v1/models` | 返回当前 Client Key 账号范围内各 Provider 的可用公开模型并集;有两种响应形态,见下 | | `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 | +| `GET` | `/v1/usage` | 查询当前 Client Key 的日与周额度,仅使用网关已结算的 USD 账本 | Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型; 网关不提供独立的子代理请求路径。 @@ -301,6 +302,30 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` 官方 Codex 的 WebSocket 客户端支持这一流程。其他客户端需要自行处理,网关不会跨账号发送原增量输入。 普通限流、容量不足、发送结果不明以及已经交付输出的失败不触发此转换。 +### API Key 额度查询 + +`GET /v1/usage` 使用 `Authorization: Bearer `,不接受会话 Cookie、管理 API Key 或查询参数。 +只返回该 Key 的日与周额度,不包含明文 Key、账号资料或其他 Key 的数据。查询不会调用上游、扣费、占用推理并发/RPM, +也不会更新最近使用时间或开启预算窗口;额度耗尽后仍可查询。 + +成功响应直接返回以下 JSON,不使用管理接口信封,所有响应带 `Cache-Control: no-store`: + +```json +{ + "unit": "USD", + "daily": { "total": "1", "used": "0.640001", "remaining": "0.359999", "resetsAt": "2026-09-21T16:00:00Z" }, + "weekly": { "total": "5", "used": "2.35", "remaining": "2.65", "resetsAt": "2026-09-27T16:00:00Z" } +} +``` + +金额使用十进制字符串,`total` 为当前周期限额,`used` 为该周期已结算金额,`remaining` 为限额减已用且最低为零。 +不限额时 `total`、`remaining` 均为 `null`,仍返回已用金额。`resetsAt` 为 RFC3339 时间,尚未开启或已到期的窗口返回 `null`, +已到期窗口的 `used` 为 `"0"`。日窗口按北京时间零点划分,周窗口沿用首次使用起的七天周期,不固定为周一。 +修改限额、管理员重置和费用结算均复用现有 Key 账本,不从请求日志重算余额。 + +缺失、非法、已禁用或已删除的 Key 返回 OpenAI 风格 `401` 错误;未知查询参数返回 `400 invalid_usage_query`, +读取账本失败返回 `503 usage_unavailable`,不会用零余额掩盖故障。 + ## 4. 浏览器认证 ### 统一登录与会话 @@ -360,7 +385,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` `kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }`。 overview 返回 `asOf`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。 -`key` 仅包含名称、掩码前缀、并发/RPM、日与七日限额、已用 USD 及重置时间;零限额表示不限, +`key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限, 未启动窗口的重置时间为 null。额度使用现有结算账本,不受日志日期或模型筛选影响。 健康时间线沿用管理端的 96 个北京时间日内桶与可用性语义,不受历史范围和模型筛选影响。 diff --git a/docs/architecture.md b/docs/architecture.md index fd9cea0ea..704d3a253 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ Codex Proxy RS 是单进程、单副本运行的多 Provider AI 网关,同时 - 面向客户端的 OpenAI Responses、Images、standalone Search 和模型目录协议; - 面向管理员的 `/api/admin/*` 控制面和 Vue 管理端; -- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口和独立 `/key-usage` 页面; +- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口、独立 `/key-usage` 页面,以及 Bearer 鉴权的 `/v1/usage` 额度查询; - OpenAI 与 xAI 两个编译期 Provider; - PostgreSQL 持久化、Redis 协调状态以及 S3/R2 数据库备份。 @@ -337,8 +337,9 @@ AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;K 管理员会话保存由已加盐密码哈希派生的指纹,每次恢复时与 PostgreSQL 当前密码核对;普通设置变更不影响该绑定。 改密在 AuthService 验证当前密码和新密码策略,Store 以旧哈希条件更新密码并在同一 PostgreSQL 事务记录审计。 事务提交后旧管理员会话的指纹失配,不依赖 Redis 批量删除完成撤销;原始密码及密码哈希不进入 Redis。 -KeyUsageService 从 AuthService 的服务端身份确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和 -ObservabilityStore 的范围查询;API 只输出单页所需的字段白名单,不复用管理员的宽响应。 +KeyUsageService 从 AuthService 的服务端身份或 Core 的 ClientKeyVerifier 只读校验确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和 +ObservabilityStore 的范围查询;Bearer 查询仅提供当前额度,不执行推理准入或开启窗口。 +API 只输出各入口所需的字段白名单,不复用管理员的宽响应。 客户端配置通过 ClientKeyStore 显式读取当前会话绑定 Key 的明文,不进入用量响应。 前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口;配置弹窗和 Codex / CCSwitch 配置生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器。 diff --git a/frontend/src/utils/ccswitchImport.ts b/frontend/src/utils/ccswitchImport.ts index 9cc1b02f5..9ec663779 100644 --- a/frontend/src/utils/ccswitchImport.ts +++ b/frontend/src/utils/ccswitchImport.ts @@ -11,6 +11,39 @@ export interface CodexCcSwitchImportInput { websocketEnabled?: boolean } +function buildUsageScript(apiKey: string, baseUrl: string) { + // CC Switch 的占位符直接替换 JS 源码,不能安全承载含引号的自定义 Key。 + // 按本次导入值生成字符串字面量,并转义左花括号,避免值中的占位符被二次替换。 + const url = JSON.stringify(`${baseUrl}/usage`).replaceAll('{', '\\u007b') + const authorization = JSON.stringify(`Bearer ${apiKey}`).replaceAll('{', '\\u007b') + // 不限额时省略 total / remaining,避免把无限额度显示成余额为零。 + return `({ + request: { + url: ${url}, + method: "GET", + headers: { Authorization: ${authorization} } + }, + extractor: function(response) { + return [["daily", "日额度"], ["weekly", "周额度"]].map(function(entry) { + var budget = response[entry[0]]; + var result = { + planName: entry[1], + isValid: true, + used: Number(budget.used), + unit: response.unit + }; + if (budget.total === null) { + result.extra = "不限额"; + } else { + result.total = Number(budget.total); + result.remaining = Number(budget.remaining); + } + return result; + }); + } +})` +} + export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput): string { const configFiles = buildCodexConfigFiles({ apiKey: input.apiKey, @@ -32,7 +65,9 @@ export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput ['apiKey', input.apiKey], ['configFormat', 'json'], ['config', config], - ['usageEnabled', 'false'], + ['usageEnabled', 'true'], + ['usageScript', encodeBase64(buildUsageScript(input.apiKey, configFiles.baseUrl))], + ['usageAutoInterval', '30'], ] return `ccswitch://v1/import?${new URLSearchParams(entries).toString()}` diff --git a/frontend/src/views/key-usage/components/KeyUsageBudget.vue b/frontend/src/views/key-usage/components/KeyUsageBudget.vue index ed1cb65a2..8b72daa62 100644 --- a/frontend/src/views/key-usage/components/KeyUsageBudget.vue +++ b/frontend/src/views/key-usage/components/KeyUsageBudget.vue @@ -8,7 +8,7 @@ import { keyUsageTime, money } from '../utils/format' const props = defineProps<{ budget: KeyUsageBudget }>() const windows = computed(() => [ { label: '今日额度', limit: props.budget.dailyLimitUsd, used: props.budget.dailyUsedUsd, reset: props.budget.dailyResetsAt }, - { label: '7日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt }, + { label: '周额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt }, ].map(window => ({ ...window, limited: Number(window.limit) > 0, diff --git a/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue b/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue index 1930485dd..a489332f7 100644 --- a/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue +++ b/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue @@ -56,7 +56,7 @@ const metrics = [