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:
Zyy
2026-09-21 00:26:36 +08:00
committed by GitHub
co-authored by Codex
parent dc0cdf2930
commit a0836ec3f2
20 changed files with 403 additions and 18 deletions
+1 -1
View File
@@ -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#公网访问)
+2 -1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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 个北京时间日内桶与可用性语义,不受历史范围和模型筛选影响。
+4 -3
View File
@@ -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
配置生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器。
+36 -1
View File
@@ -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