mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(key-usage): add API key budgets to CC Switch imports (#202)
Closes #198 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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#公网访问)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Option<ClientBudgetStatus>, AdminError>;
|
||||
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
|
||||
|
||||
async fn config(&self, session_id: Option<&str>)
|
||||
@@ -48,6 +57,7 @@ pub trait KeyUsageService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
@@ -56,12 +66,14 @@ pub(crate) struct DefaultKeyUsageService {
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
verifier,
|
||||
keys,
|
||||
observations,
|
||||
system,
|
||||
@@ -95,6 +107,24 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, 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<Option<SystemVersion>, AdminError> {
|
||||
if self.key_id(session_id).await?.is_none() {
|
||||
return Ok(None);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ApiState> {
|
||||
.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())
|
||||
|
||||
@@ -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<ApiState> {
|
||||
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<ApiState>,
|
||||
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::<UsageQuery>::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<SystemTime>) -> 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::<Utc>::from),
|
||||
})
|
||||
}
|
||||
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ mod models;
|
||||
mod responses;
|
||||
mod router;
|
||||
mod search;
|
||||
mod usage;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
@@ -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::<Utc>::from),
|
||||
},
|
||||
"weekly": {
|
||||
"total": "5", "used": "2.35", "remaining": "2.65",
|
||||
"resetsAt": before.budget.weekly_resets_at.map(chrono::DateTime::<Utc>::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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
self.0
|
||||
|
||||
@@ -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 设置应写入用户配置,不要只写到项目目录。
|
||||
|
||||
+27
-2
@@ -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 <Client Key>`,不接受会话 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 个北京时间日内桶与可用性语义,不受历史范围和模型筛选影响。
|
||||
|
||||
|
||||
@@ -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
|
||||
配置生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器。
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,7 +56,7 @@ const metrics = [
|
||||
<BaseCard title="额度概览" aria-busy="true">
|
||||
<div class="flex flex-1 flex-col justify-between gap-6" aria-hidden="true">
|
||||
<div class="grid flex-1 gap-6 sm:grid-cols-2">
|
||||
<div v-for="label in ['今日额度', '7日额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
|
||||
<div v-for="label in ['今日额度', '周额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-cp-sm text-cp-text-secondary">
|
||||
{{ label }}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatDateTime } from '@/utils/date'
|
||||
const props = defineProps<{ apiKey: ApiKey }>()
|
||||
const windows = computed(() => [
|
||||
{ label: '日', heading: '日用量', used: props.apiKey.dailyUsedUsd, limit: props.apiKey.dailyLimitUsd, reset: props.apiKey.dailyResetsAt },
|
||||
{ label: '周', heading: '7日用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
|
||||
{ label: '周', heading: '周用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
|
||||
])
|
||||
function amount(value: string) {
|
||||
// 列表最多显示两位小数,不补末尾零;明细保留原始金额的全部精度。
|
||||
|
||||
@@ -17,12 +17,12 @@ const { loading, run } = useAsyncAction()
|
||||
const periods = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '日额度', value: 'daily' },
|
||||
{ label: '7日额度', value: 'weekly' },
|
||||
{ label: '周额度', value: 'weekly' },
|
||||
]
|
||||
const windows = computed(() => props.apiKey
|
||||
? [
|
||||
{ value: 'daily', label: '日额度', used: props.apiKey.dailyUsedUsd },
|
||||
{ value: 'weekly', label: '7日额度', used: props.apiKey.weeklyUsedUsd },
|
||||
{ value: 'weekly', label: '周额度', used: props.apiKey.weeklyUsedUsd },
|
||||
].map(window => ({ ...window, selected: period.value === 'all' || period.value === window.value }))
|
||||
: [])
|
||||
|
||||
|
||||
@@ -153,13 +153,13 @@ const profileProviderOptions = [
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="7日限额">
|
||||
<BaseFormItem label="周限额">
|
||||
<BaseInput
|
||||
v-model="form.weeklyLimitUsd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="7日限额(美元)"
|
||||
aria-label="周限额(美元)"
|
||||
placeholder="不限制"
|
||||
:disabled="saving"
|
||||
>
|
||||
|
||||
@@ -141,7 +141,7 @@ export function useApiKeyMutations(options: {
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['7日限额', form.value.weeklyLimitUsd]]) {
|
||||
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['周限额', form.value.weeklyLimitUsd]]) {
|
||||
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
|
||||
toast.warning(`${label}必须是非负金额,最多 10 位小数`)
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user