Compare commits

...
6 Commits
Author SHA1 Message Date
ZyyandCodex 9c10676764 chore(release): v3.18.3
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 22:20:06 +08:00
ZyyandCodex caa6c8ffc8 refactor(plugin): keep external quota cycles out of host ledger (#332)
Restore the pre-window host implementation and document plugin-owned policy and presentation. Keep native reset RPC coverage and run plugin integration tests with the standard isolated test services.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 21:47:50 +08:00
9d651856be fix(outbound): avoid duplicate Host headers on HTTP/2 (#337)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 19:26:58 +08:00
ZyyandCodex d10b8890e0 fix(admin): improve quota forecast and subscription display
Move the quota forecast entry after reset credits and propagate account updates from the quota panel. Include each error account current subscription in troubleshooting and unify subscription labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 18:45:08 +08:00
ZyyandCodex d97c6e064b fix(keys): restart budget windows on use after reset (#335)
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 17:54:47 +08:00
ZyyandCodex f85801edb3 fix(usage): balance diagnostic column widths
Size diagnostic names by dimension and share available width across metric columns. Keep API key names compact.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 04:33:21 +08:00
36 changed files with 612 additions and 111 deletions
@@ -809,6 +809,8 @@ pub struct OpsError {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -382,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService {
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
self.store
let mut page = self
.store
.list_ops_errors(query)
.await
.map_err(|error| map_store_error(error, "operations errors"))
.map_err(|error| map_store_error(error, "operations errors"))?;
for error in &mut page.items {
error.provider_account_plan_type_display =
error.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
});
}
Ok(page)
}
}
@@ -583,6 +583,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
account_id: error.provider_account_ref,
account_name: error.provider_account_name,
account_email: error.provider_account_email,
account_plan_type: error.provider_account_plan_type,
account_plan_type_display: error.provider_account_plan_type_display,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model,
requested_model: error.requested_model_id,
@@ -792,6 +792,8 @@ pub struct OpsErrorView {
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -447,6 +447,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
provider_account_ref: Some("acct_err".to_owned()),
provider_account_name: None,
provider_account_email: Some("err@example.invalid".to_owned()),
provider_account_plan_type: Some("pro".to_owned()),
provider_account_plan_type_display: Some("Pro".to_owned()),
provider_account_authentication_kind: Some("api_key".to_owned()),
upstream_model_id: Some("upstream-err".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -509,6 +511,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": value["data"]["items"][0]["kind"],
"accountId": value["data"]["items"][0]["accountId"],
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"accountPlanType": value["data"]["items"][0]["accountPlanType"],
"accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
@@ -528,6 +532,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": "model_request",
"accountId": "acct_err",
"accountLabel": "err@example.invalid",
"accountPlanType": "pro",
"accountPlanTypeDisplay": "Pro",
"clientStatusCode": 502,
"route": "/v1/responses",
"requestedModel": "gpt-5.4",
@@ -168,6 +168,8 @@ fn error_record() -> OpsError {
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
+1
View File
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
[dev-dependencies]
filetime = "0.2.27"
hyper = { version = "=1.11.1", default-features = false, features = ["server"] }
tempfile = "3.27.0"
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
wiremock = "0.6.5"
@@ -263,18 +263,18 @@ impl HttpClient {
.as_str()
.parse()
.map_err(|_| HttpError::invalid("URL"))?;
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
if url.scheme() == "http"
&& let Some(proxy) = proxy.filter(|proxy| proxy.expose_url().starts_with("http"))
{
// 正向代理接收数字地址的绝对 URI;Host 保留原始域名,禁止代理再次解析目标。
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
let address = addresses
.first()
.ok_or_else(|| HttpError::invalid("address"))?;
@@ -129,6 +129,7 @@ impl Downloads {
continue;
}
if !(200..300).contains(&response.status) {
tracing::warn!(status = response.status, "插件来源返回非成功状态");
return Err(source_error());
}
if header(&response.headers, "content-length")
@@ -17,6 +17,37 @@ fn local_network() -> NetworkPolicy {
NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]).unwrap()
}
#[tokio::test]
async fn managed_https_http2_uses_authority_without_an_extra_host_header() {
use std::sync::Arc;
use crate::support::network::{FixedDns, Mode, ROOT, Server};
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
let url = format!("https://upstream.test:{}/query", server.address.port());
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
.unwrap()
.with_resolver(Arc::new(FixedDns(server.address.ip())));
let mut response = client
.open(
request(url.clone()),
None,
&local_network(),
Duration::from_secs(3),
)
.await
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body.read(64).await.unwrap().unwrap(), "secured");
let observation = server.next().await;
assert!(
observation
.head
.starts_with(&format!("GET {url} HTTP/2.0\r\n"))
);
assert_eq!(observation.sni.as_deref(), Some("upstream.test"));
}
#[tokio::test]
async fn managed_http_does_not_follow_redirects_or_fall_back_from_proxy() {
let server = MockServer::start().await;
@@ -1,13 +1,118 @@
use std::sync::Arc;
use gateway_admin::{
model::plugins::distribution::{RemotePluginLocation, SourceAuthentication},
model::{
AdminErrorKind,
plugins::distribution::{
DownloadPurpose, RemotePluginLocation, SourceAuthentication, SourceCredential,
SourceCredentialInfo,
},
},
ports::plugins::PluginDistribution,
};
use gateway_host::{
outbound::{HttpClient, NetworkPolicy},
plugin_distribution::HttpPluginDistribution,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
use super::{credential, credential_with, digest, transport};
use crate::support::network::{FixedDns, Mode, ROOT, Server};
#[tokio::test]
async fn https_http2_download_preserves_scoped_credentials_and_digest_verification() {
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
let origin = format!("https://upstream.test:{}", server.address.port());
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
.unwrap()
.with_resolver(Arc::new(FixedDns(server.address.ip())));
let distribution = HttpPluginDistribution::with_transport(
&origin,
Arc::new(client),
NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap(),
)
.unwrap();
let result = distribution
.download(
RemotePluginLocation::Url {
url: format!("{origin}/private/plugin.tar.gz"),
sha256: Some(digest(b"secured")),
},
vec![SourceCredential {
info: SourceCredentialInfo {
id: "test-credential".into(),
name: "Test".into(),
origin,
path_prefix: "/private".into(),
purposes: vec![DownloadPurpose::Artifact],
},
authentication: SourceAuthentication::Bearer {
token: "test-token".into(),
},
}],
None,
)
.await
.unwrap();
assert_eq!(&*result.archive, b"secured");
assert!(
server
.next()
.await
.head
.contains("authorization: Bearer test-token\r\n")
);
}
#[tokio::test]
async fn source_failures_log_status_without_credentials_url_or_response_body() {
use std::io::{Read as _, Seek as _, SeekFrom};
use tracing::instrument::WithSubscriber as _;
let server = MockServer::start().await;
Mock::given(path("/private/plugin"))
.respond_with(ResponseTemplate::new(400).set_body_string("private upstream response"))
.expect(1)
.mount(&server)
.await;
let mut output = tempfile::tempfile().unwrap();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.without_time()
.json()
.with_writer(output.try_clone().unwrap())
.finish();
let error = transport(&server)
.download(
RemotePluginLocation::Url {
url: format!("{}/private/plugin", server.uri()),
sha256: None,
},
vec![credential(&server, "/private")],
None,
)
.with_subscriber(subscriber)
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::BadGateway);
assert_eq!(
error.to_string(),
"插件来源请求失败,请检查来源与下载授权后重试"
);
output.seek(SeekFrom::Start(0)).unwrap();
let mut log = String::new();
output.read_to_string(&mut log).unwrap();
let event: serde_json::Value = serde_json::from_str(&log).unwrap();
assert_eq!(event["fields"]["status"], 400);
for sensitive in ["test-token", "/private/plugin", "private upstream response"] {
assert!(!log.contains(sensitive));
}
}
#[tokio::test]
async fn url_preview_computes_digest_and_confirmation_rejects_replaced_content() {
@@ -36,6 +36,7 @@ pub struct Observation {
#[derive(Clone, Copy)]
pub enum Mode {
Origin,
Http2Origin,
HttpProxy,
Socks,
}
@@ -57,7 +58,7 @@ impl Server {
let listener = TcpListener::bind(bind).await.unwrap();
let address = listener.local_addr().unwrap();
let (sender, received) = mpsc::unbounded_channel();
let acceptor = tls.then(tls_acceptor);
let acceptor = tls.then(|| tls_acceptor(matches!(mode, Mode::Http2Origin)));
let task = tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
@@ -104,6 +105,9 @@ async fn serve(
mode: Mode,
observations: mpsc::UnboundedSender<Observation>,
) -> io::Result<()> {
if matches!(mode, Mode::Http2Origin) {
return serve_http2(stream, sni, observations).await;
}
if matches!(mode, Mode::Socks) {
return socks(stream, observations).await;
}
@@ -163,6 +167,50 @@ async fn serve(
Ok(())
}
async fn serve_http2(
stream: Box<dyn Socket>,
sni: Option<String>,
observations: mpsc::UnboundedSender<Observation>,
) -> io::Result<()> {
let service =
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
// 模拟 nginx 1.24:URI 已携带 :authority,额外的 Host 仍按重复头拒绝。
let status = if request.headers().contains_key(http::header::HOST) {
400
} else {
200
};
let mut head = format!(
"{} {} {:?}\r\n",
request.method(),
request.uri(),
request.version()
);
for (name, value) in request.headers() {
head.push_str(&format!("{name}: {}\r\n", value.to_str().unwrap()));
}
observations
.send(Observation {
head,
sni: sni.clone(),
target: None,
authentication: None,
})
.unwrap();
let response = http::Response::builder()
.status(status)
.body(http_body_util::Full::new(bytes::Bytes::from_static(
b"secured",
)))
.unwrap();
async { Ok::<_, std::convert::Infallible>(response) }
});
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
.await
.map_err(io::Error::other)
}
async fn read_head(stream: &mut Box<dyn Socket>) -> io::Result<String> {
let mut bytes = Vec::new();
while !bytes.ends_with(b"\r\n\r\n") {
@@ -221,12 +269,12 @@ async fn socks(
Ok(())
}
fn tls_acceptor() -> TlsAcceptor {
fn tls_acceptor(http2: bool) -> TlsAcceptor {
use rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
};
let config = ServerConfig::builder_with_provider(Arc::new(
let mut config = ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
@@ -241,5 +289,8 @@ fn tls_acceptor() -> TlsAcceptor {
)),
)
.unwrap();
if http2 {
config.alpn_protocols = vec![b"h2".to_vec()];
}
TlsAcceptor::from(Arc::new(config))
}
@@ -106,7 +106,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
"client_key_id":"key_budget", "daily_limit_usd":"10", "weekly_limit_usd":"20",
"daily_used_usd":"3", "weekly_used_usd":"0",
"daily_resets_at_ms":before.budget.daily_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
"weekly_resets_at_ms":before.budget.weekly_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
"weekly_resets_at_ms":null,
});
assert_eq!(results[6], expected);
assert_eq!(results[7], json!({"client_key_id":"key_budget"}));
@@ -126,10 +126,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
1
);
assert_eq!(after.budget.daily_resets_at, before.budget.daily_resets_at);
assert_eq!(
after.budget.weekly_resets_at,
before.budget.weekly_resets_at
);
assert_eq!(after.budget.weekly_resets_at, None);
assert_eq!(audits.len(), 1);
assert!(audits[0].starts_with(&format!("plugin:{}:scope:", view.target.instance_id)));
}
@@ -644,8 +644,24 @@ impl Environment {
}
async fn create_with_store_mode(command_line: bool) -> Option<Self> {
let database = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL").ok()?;
let redis = std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL");
// 插件专用服务优先;CI 的标准测试服务同样通过随机 schema 隔离数据库。
let (database, redis) = if let Ok(database) = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL")
{
(
database,
std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL"),
)
} else {
let database = std::env::var("CPR_TEST_DATABASE_URL").ok();
assert!(
database.is_some() || std::env::var_os("CI").is_none(),
"CI requires plugin or standard test services"
);
(
database?,
std::env::var("CPR_TEST_REDIS_URL").expect("isolated test Redis URL"),
)
};
let schema = format!("cpr_plugin_{}", uuid::Uuid::new_v4().simple());
let admin = PgPoolOptions::new()
.max_connections(1)
@@ -178,7 +178,7 @@ Key 明文仍通过宿主管理面查看,插件模型调用使用返回的 Key
相同值再次赋值不产生新 revision 或审计;并发更新按事务顺序生效,同一字段由后提交的值覆盖,接口不提供调用去重或比较交换。
将非零上限降低到已用金额及以下会拒绝后续准入,提高限额可恢复准入,在途请求仍按原生规则完成和结算
重置只清零所选周期,保留额度上限、密钥、窗口到期时间和历史费用事件;未使用的 Key 不因此开启窗口。
重置清零所选周期并清除到期时间,保留额度上限、密钥和历史费用事件;所选窗口下次使用时按原生规则重新开启。
费用按完成时间归属,重置前完成但延迟落盘的费用不会重新扣入已重置周期,重置后完成的在途请求仍正常扣额。
实例版本复验、清零和审计在同一事务提交,不推进配置 revision。每次调用均执行新重置,可能清掉两次调用之间的新消费
@@ -186,6 +186,12 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限
不存在的 Key 返回 `rejected`;写入事务复验发现实例停用或版本变化时返回 `conflict`,
非法输入返回 `invalid_input`。账号关联、预算分配和重置触发由插件决定,宿主不自动串联上述接口
插件的外部周期、截止时间和同步进度保存在自身 `host.state.*` 中,并通过自己的管理页面展示;
这些数据不会改写宿主日/周窗口及其显示。插件可在 `request` 中间件中按自己的策略提前拒绝请求,
在确认新周期后调用预算重置或上限修改接口;调用仍遵循上述原生语义,宿主窗口继续按自身日期规则滚动。
私有状态写入与预算操作不是一个事务,不能把私有状态标记当作预算操作已提交的证明;结果未知时须按上述合同处理。
停用插件会停止其后续拦截与同步,已执行的额度修改或重置不会自动撤销
### Key、模型与模型调用
模型调用方法:
@@ -53,7 +53,7 @@ impl HostClient {
serde_json::from_value(reply.result).map_err(|_| invalid())
}
/// 清零指定周期,保留限额和到期时间;结果未知时不能盲目重试。
/// 清零并关闭指定周期,保留限额,下次使用时重新开启;结果未知时不能盲目重试。
///
/// # Errors
/// 实例过期、Key 不存在、宿主写入失败时返回错误。
@@ -78,13 +78,15 @@ async fn reset_client_key_budget_in_transaction(
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
);
let reset_at = Utc::now();
// 推进计费起点,避免重置前完成、稍后落盘的费用重新扣额;未使用的 Key 不开启窗口。
// 起止时间收拢到重置边界,窗口保持未开启,同时排除重置前完成的迟到费用。
sqlx::query(
"update client_key_budget_windows set
daily_used_usd = case when $2 then 0 else daily_used_usd end,
daily_start = case when $2 and daily_end > $4 then $4 else daily_start end,
daily_start = case when $2 then $4 else daily_start end,
daily_end = case when $2 then $4 else daily_end end,
weekly_used_usd = case when $3 then 0 else weekly_used_usd end,
weekly_start = case when $3 and weekly_end > $4 then $4 else weekly_start end
weekly_start = case when $3 then $4 else weekly_start end,
weekly_end = case when $3 then $4 else weekly_end end
where client_api_key_id = $1",
)
.bind(command.id.as_str())
@@ -96,10 +98,18 @@ async fn reset_client_key_budget_in_transaction(
.map_err(|_| postgres_unavailable("reset client budget"))?;
let mut fields = Vec::new();
if daily {
fields.extend(["daily_used_usd".to_owned(), "daily_start".to_owned()]);
fields.extend([
"daily_used_usd".to_owned(),
"daily_start".to_owned(),
"daily_end".to_owned(),
]);
}
if weekly {
fields.extend(["weekly_used_usd".to_owned(), "weekly_start".to_owned()]);
fields.extend([
"weekly_used_usd".to_owned(),
"weekly_start".to_owned(),
"weekly_end".to_owned(),
]);
}
super::append_admin_audit_event_in_transaction(
tx,
@@ -174,7 +184,7 @@ impl PgClientBudgetStore {
.map_err(|_| unavailable())?,
};
let now = Utc::now();
advance_windows(&mut tx, key_id.as_str(), now)
advance_windows(&mut tx, key_id.as_str(), now, now)
.await
.map_err(|_| unavailable())?;
if limits.is_limited() {
@@ -243,7 +253,7 @@ async fn settle_in_transaction(
key: &str,
charge: &ClientBudgetCharge,
) -> Result<(), sqlx::Error> {
advance_windows(tx, key, Utc::now()).await?;
let completed_at = DateTime::<Utc>::from(charge.completed_at);
// 仅在请求结束时写入费用;请求 ID 冲突时不重复累计。
let changed = sqlx::query(
"insert into client_key_charge_events (request_id, client_api_key_id, amount_usd, completed_at)
@@ -253,16 +263,17 @@ async fn settle_in_transaction(
.bind(charge.request_id.as_str())
.bind(key)
.bind(charge.amount_usd.canonical())
.bind(DateTime::<Utc>::from(charge.completed_at))
.bind(completed_at)
.execute(&mut **tx)
.await?
.rows_affected();
if changed == 1 {
advance_windows(tx, key, Utc::now(), completed_at).await?;
sqlx::query("update client_key_budget_windows set
daily_used_usd = daily_used_usd + case when $3 >= daily_start and $3 < daily_end then $2::text::numeric else 0 end,
weekly_used_usd = weekly_used_usd + case when $3 >= weekly_start and $3 < weekly_end then $2::text::numeric else 0 end
where client_api_key_id = $1")
.bind(key).bind(charge.amount_usd.canonical()).bind(DateTime::<Utc>::from(charge.completed_at))
.bind(key).bind(charge.amount_usd.canonical()).bind(completed_at)
.execute(&mut **tx).await?;
}
Ok(())
@@ -291,19 +302,21 @@ async fn advance_windows(
tx: &mut Transaction<'_, Postgres>,
key: &str,
now: DateTime<Utc>,
used_at: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
// 到期日仍按北京时间零点计算,计费起点不能退回重置前;迟到费用不启动新窗口。
sqlx::query("insert into client_key_budget_windows
(client_api_key_id, daily_start, daily_end, weekly_start, weekly_end)
select $1, day, day + interval '24 hours', day, day + interval '168 hours'
from (select date_trunc('day', $2::timestamptz at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai' as day) d
on conflict (client_api_key_id) do update set
daily_start = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_start else client_key_budget_windows.daily_start end,
daily_end = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_end else client_key_budget_windows.daily_end end,
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 then 0 else client_key_budget_windows.daily_used_usd end,
weekly_start = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_start else client_key_budget_windows.weekly_start end,
weekly_end = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 then 0 else client_key_budget_windows.weekly_used_usd end")
.bind(key).bind(now).execute(&mut **tx).await?;
daily_start = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then greatest(excluded.daily_start, client_key_budget_windows.daily_start) else client_key_budget_windows.daily_start end,
daily_end = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then excluded.daily_end else client_key_budget_windows.daily_end end,
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then 0 else client_key_budget_windows.daily_used_usd end,
weekly_start = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then greatest(excluded.weekly_start, client_key_budget_windows.weekly_start) else client_key_budget_windows.weekly_start end,
weekly_end = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then 0 else client_key_budget_windows.weekly_used_usd end")
.bind(key).bind(now).bind(used_at).execute(&mut **tx).await?;
Ok(())
}
@@ -757,6 +757,8 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
provider_account_ref: error.provider_account_ref,
provider_account_name: error.provider_account_name,
provider_account_email: error.provider_account_email,
provider_account_plan_type: error.provider_account_plan_type,
provider_account_plan_type_display: None,
provider_account_authentication_kind: error.provider_account_authentication_kind,
upstream_model_id: error.upstream_model_id,
upstream_transport: error.upstream_transport,
@@ -955,6 +957,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -722,6 +722,7 @@ pub struct OpsErrorRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -10,6 +10,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.plan_type as provider_account_plan_type,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport,
@@ -33,6 +34,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
'model_request:' || mr.id as stable_sort_id
from model_requests mr
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = mr.provider_account_ref
where true";
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
@@ -42,6 +44,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
oe.provider_account_email_snapshot as provider_account_email,
account.plan_type as provider_account_plan_type,
oe.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind,
@@ -66,6 +69,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
from ops_events oe
left join model_requests mr on mr.id = oe.model_request_id
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = oe.provider_account_ref
where true";
pub(crate) async fn list_ops_errors(
@@ -75,7 +75,7 @@ fn context() -> MutationContext {
}
#[tokio::test]
async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_and_expiry() {
async fn manual_reset_clears_selected_windows_and_preserves_policy_and_history() {
let Some(database) = TestDatabase::create("budget_manual_reset").await else {
return;
};
@@ -108,14 +108,25 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
)
.await
.unwrap();
// 同一费用重试仍由事件 ID 去重,不因清零而被再次累计。
store.settle(billed).await.unwrap();
let after = status(&database, key).await;
assert_eq!(after.daily_used_usd.canonical(), daily);
assert_eq!(after.weekly_used_usd.canonical(), weekly);
assert_eq!(after.limits, before.limits);
assert_eq!(after.daily_resets_at, before.daily_resets_at);
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
assert_eq!(
after.daily_resets_at,
(period == ClientKeyBudgetPeriod::Weekly)
.then_some(before.daily_resets_at)
.flatten()
);
assert_eq!(
after.weekly_resets_at,
(period == ClientKeyBudgetPeriod::Daily)
.then_some(before.weekly_resets_at)
.flatten()
);
// 同一费用重试既不重复扣额,也不能重新开启已重置的窗口。
store.settle(billed).await.unwrap();
assert_eq!(status(&database, key).await, after);
assert_eq!(
store.admit(key_id(key)).await.is_ok(),
period == ClientKeyBudgetPeriod::All
@@ -130,6 +141,118 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
database.close().await;
}
#[tokio::test]
async fn manual_reset_restarts_selected_windows_only_after_new_usage() {
let Some(database) = TestDatabase::create("budget_reset_restart").await else {
return;
};
let store = PgClientBudgetStore::new(database.pool.clone());
let admin = PgAdminClientKeyStore::new(database.pool.clone());
for (key, period) in [
("daily", ClientKeyBudgetPeriod::Daily),
("weekly", ClientKeyBudgetPeriod::Weekly),
("all", ClientKeyBudgetPeriod::All),
] {
seed(&database, key, "10", "20").await;
store.admit(key_id(key)).await.unwrap();
store.settle(charge(key, key, "1")).await.unwrap();
sqlx::query("update client_key_budget_windows set weekly_start = weekly_start - interval '3 days', weekly_end = weekly_end - interval '3 days' where client_api_key_id = $1")
.bind(key).execute(&database.pool).await.unwrap();
let before = status(&database, key).await;
let delayed = charge(key, &format!("{key}-delayed"), "0.5");
let old = charge(key, &format!("{key}-old"), "0.7");
admin
.reset_client_key_budget(
ResetClientKeyBudget {
id: key_id(key),
period,
},
ClientKeyBudgetMutationOrigin::Admin,
&context(),
)
.await
.unwrap();
let reset = status(&database, key).await;
// 重置前完成的迟到费用保留历史,但不能开启窗口或重新扣入所选周期。
store.settle(delayed).await.unwrap();
let delayed = status(&database, key).await;
assert_eq!(delayed.daily_resets_at, reset.daily_resets_at);
assert_eq!(delayed.weekly_resets_at, reset.weekly_resets_at);
assert_eq!(
delayed.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"1.5"
} else {
"0"
}
);
assert_eq!(
delayed.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"1.5"
} else {
"0"
}
);
store.admit(key_id(key)).await.unwrap();
let restarted = status(&database, key).await;
assert_eq!(restarted.daily_resets_at, before.daily_resets_at);
if period == ClientKeyBudgetPeriod::Daily {
assert_eq!(restarted.weekly_resets_at, before.weekly_resets_at);
} else {
assert_eq!(
restarted.weekly_resets_at.unwrap(),
restarted.daily_resets_at.unwrap() + std::time::Duration::from_secs(6 * 86400)
);
assert_ne!(restarted.weekly_resets_at, before.weekly_resets_at);
}
// 开启窗口后仍保留重置边界,另一笔重置前的迟到费用也不能重新扣额。
store.settle(old).await.unwrap();
let after_old = status(&database, key).await;
assert_eq!(after_old.daily_resets_at, restarted.daily_resets_at);
assert_eq!(after_old.weekly_resets_at, restarted.weekly_resets_at);
assert_eq!(
after_old.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"2.2"
} else {
"0"
}
);
assert_eq!(
after_old.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"2.2"
} else {
"0"
}
);
store
.settle(charge(key, &format!("{key}-new"), "0.25"))
.await
.unwrap();
let used = status(&database, key).await;
assert_eq!(
used.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"2.45"
} else {
"0.25"
}
);
assert_eq!(
used.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"2.45"
} else {
"0.25"
}
);
}
database.close().await;
}
#[tokio::test]
async fn manual_reset_excludes_old_completions_but_counts_inflight_requests_after_reset() {
let Some(database) = TestDatabase::create("budget_reset_late").await else {
@@ -648,7 +771,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
assert_eq!(after.weekly_used_usd.canonical(), "0");
assert_eq!(after.limits, before.limits);
assert_eq!(after.daily_resets_at, before.daily_resets_at);
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
assert!(after.weekly_resets_at.is_none());
budgets.settle(charge("key", "after", "2")).await.unwrap();
assert_eq!(
@@ -666,6 +789,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
let after = status(&database, "key").await;
assert_eq!(after.daily_used_usd.canonical(), "5");
assert_eq!(after.weekly_used_usd.canonical(), "0");
assert!(after.weekly_resets_at.is_none());
let (audits, charges, current_revision): (i64, i64, i64) = sqlx::query_as(
"select (select count(*) from admin_audit_events where action='reset_budget' and config_revision is null),
(select count(*) from client_key_charge_events),
@@ -653,6 +653,114 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
database.close().await;
}
#[tokio::test]
async fn ops_errors_should_use_each_event_accounts_current_subscription() {
let Some(database) = TestDatabase::create("ops_account_subscription").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id, upstream_account_id,
plan_type, authentication_kind, provider_credentials_json,
credential_revision, credential_observed_at, has_refresh_token,
created_at, updated_at
) select 'acct_retry', provider_kind, name, email, upstream_user_id,
'retry-workspace', 'team', authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
from provider_accounts where id = 'acct_observe'",
)
.execute(&database.pool)
.await
.expect("seed same-email retry account");
sqlx::query(
"update ops_events set provider_account_id = 'acct_retry',
provider_account_ref = 'acct_retry'
where id = 'ops_observe_retry'",
)
.execute(&database.pool)
.await
.expect("assign retry event to its own account");
let query = admin_observability::OpsErrorQuery {
range: admin_observability::TimeRange::new(
now - TimeDelta::hours(1),
now + TimeDelta::hours(1),
)
.expect("ops range"),
filter: admin_observability::OpsErrorFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
};
let store = admin_observability_store(&database.pool);
for plan in [Some("pro"), Some("plus"), None] {
sqlx::query("update provider_accounts set plan_type = $1 where id = 'acct_observe'")
.bind(plan)
.execute(&database.pool)
.await
.expect("update current subscription");
let page = store
.list_ops_errors(query.clone())
.await
.expect("ops page");
assert_eq!(page.total, 2);
assert_eq!(page.items.len(), 2);
let request = page
.items
.iter()
.find(|error| error.source == "model_request")
.expect("request error");
let event = page
.items
.iter()
.find(|error| error.source == "ops_event")
.expect("retry event");
assert_eq!(request.provider_account_email, event.provider_account_email);
assert_eq!(request.provider_account_plan_type.as_deref(), plan);
assert_eq!(event.provider_account_plan_type.as_deref(), Some("team"));
}
sqlx::query(
"update ops_events set model_request_id = null, attempt_index = null
where id = 'ops_observe_retry'",
)
.execute(&database.pool)
.await
.expect("detach event from request");
let page = store
.list_ops_errors(query.clone())
.await
.expect("ops page");
assert_eq!(
page.items
.iter()
.find(|error| error.source == "ops_event")
.expect("standalone event")
.provider_account_plan_type
.as_deref(),
Some("team")
);
sqlx::query("delete from provider_accounts where id = 'acct_retry'")
.execute(&database.pool)
.await
.expect("delete retry account");
let page = store
.list_ops_errors(query)
.await
.expect("deleted account history");
assert_eq!(page.total, 2);
let event = page
.items
.iter()
.find(|error| error.source == "ops_event")
.expect("deleted account event");
assert_eq!(event.provider_account_ref.as_deref(), Some("acct_retry"));
assert_eq!(event.provider_account_plan_type, None);
database.close().await;
}
#[tokio::test]
async fn ops_should_include_incomplete_upstream_errors() {
let Some(database) = TestDatabase::create("ops_incomplete_error").await else {
+4 -4
View File
@@ -1128,8 +1128,8 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。
记账和限额比较保留完整精度。
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
手动重置仅清零所选周期的已用金额,保留限额上限、原到期时间和历史费用,返回 `{ id }`。
未使用或已过期的窗口不会因手动重置而重新开启。重置前完成但延迟结算的费用不再计入所选周期;
手动重置清零所选周期的已用金额并清除到期时间,保留限额上限和历史费用,返回 `{ id }`。
所选窗口的重置时间返回 `null`,下次使用时按新建 Key 的规则重新开启。重置前完成但延迟结算的费用不再计入所选周期,也不会开启窗口;
重置后完成的请求继续计费,包括重置时仍在进行的请求。操作保留管理员审计,不改变账号上游额度。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
@@ -1562,8 +1562,8 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可
`requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比
`retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次
账号维度诊断项和使用记录列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前套餐及展示名称,
按内部账号 ID 关联;套餐未知或账号已删除时为 `null`,不作为请求发生时的套餐快照
账号维度诊断项、使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前订阅及展示名称,
按各记录的内部账号 ID 关联;订阅未知或账号已删除时为 `null`,不作为请求发生时的订阅快照
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属
+6 -3
View File
@@ -556,11 +556,13 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准
日金额、七天金额、并发和 RPM 按 Client Key 跨账号、跨 Provider 合计,零表示不限;修改限额不重置已用金额。
Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置。
Admin 的手动重置复用同一账本与 Key 行锁,在一个事务中清零所选周期金额、推进计费起点并写入审计,
保留窗口到期时间与费用事件,不推进配置 revision。结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期。
关闭所选窗口并保留费用事件,不推进配置 revision,下次使用时重新开启窗口。
结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期或开启窗口。
插件预算回调通过 `PluginClientKeyAccess` 进入同一 `ClientKeyService` 和 `ClientKeyStore`,存储事务先复核插件实例版本,
再执行原生预算操作;不建立另一份预算状态。查询复用账本投影,不触发准入。局部更新金额上限不改用量或窗口,
仅在值变化时提交配置 revision、审计并发布;重置仍不推进配置版本。重复调用及结果未知的合同见
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)。插件的外部周期和截止时间属于插件私有状态,展示在插件页面;
宿主预算接口和管理页面只表达原生窗口事实,不为插件周期修改滚动规则或重置语义
- 日窗口按北京时间零点划分;七天窗口从首次准入当天零点开始,到期后由下一次使用重新开启,不固定为周一
- 金额优先使用 Provider 上报的 USD,否则按现有模型价格估算;订阅账号的估算费用不代表上游订阅账单。
@@ -848,7 +850,8 @@ RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --
其他检查与界面验证按 [贡献与审查](../CONTRIBUTING.md#验证) 执行
插件 Runtime 的真实子进程与持久化测试使用 `CPR_PLUGIN_TEST_DATABASE_URL` 和
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例,密码及隔离要求与上述 Store 测试一致。
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例;未提供插件专用变量时复用 `CPR_TEST_DATABASE_URL` 与 `CPR_TEST_REDIS_URL`。
密码及隔离要求与上述 Store 测试一致,CI 缺少服务配置时直接失败。
设置 `CPR_PLUGIN_TEST_LIVE_HTTP=1` 会额外请求 GitHub 公共 HTTPS API,验证受管出站链路;这不代表模型推理验收。
若测试环境使用 Fake-IP 或私网 DNS,需通过 `CPR_PLUGIN_TEST_LIVE_NETWORK_RANGES` 显式提供逗号分隔的
CIDR 授权。该选项只用于真实网络测试,默认为空,不改变生产网络策略或其他测试的授权
+2
View File
@@ -278,6 +278,8 @@ export interface OpsError {
accountId: string | null
accountName: string | null
accountEmail: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
route: string
model: string | null
requestedModel: string | null
@@ -2,12 +2,13 @@
import type { AccountRow } from '../../constants'
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
import { RefreshCw, UserRound } from '@lucide/vue'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountProfileModal from '../AccountProfileModal/index.vue'
import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
@@ -17,6 +18,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
refreshQuota: [accountId: string]
quotaReset: [accountId: string]
}>()
@@ -25,8 +27,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const profileOpen = shallowRef(false)
const forecastOpen = shallowRef(false)
const hasForecast = computed(() => props.account.authenticationKind !== 'api_key')
const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription)
const hasActions = computed(() => hasPersonalInfo.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
watch(hasPersonalInfo, (available) => {
if (!available)
profileOpen.value = false
@@ -69,6 +73,17 @@ watch(hasPersonalInfo, (available) => {
:account="account"
@consumed="emit('quotaReset', $event)"
/>
<BaseIconButton
v-if="hasForecast"
label="预测周/月额度"
size="sm"
variant="ghost"
aria-haspopup="dialog"
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
<BaseIconButton
v-if="account.capabilities.quotaRefresh"
variant="ghost"
@@ -107,4 +122,10 @@ watch(hasPersonalInfo, (available) => {
v-model="profileOpen"
:account="account"
/>
<AccountQuotaForecastModal
v-if="hasForecast"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
@@ -1,21 +1,15 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { BaseIconButton, BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { ChartNoAxesCombined, Sigma } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
import AccountQuotaForecastModal from './AccountQuotaForecastModal/index.vue'
const props = defineProps<{
account: AccountRow
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
}>()
const forecastOpen = ref(false)
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
@@ -80,21 +74,9 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div class="min-w-0 pt-4 xl:flex xl:min-h-0 xl:flex-col xl:pt-0 xl:pl-4">
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">
<div class="flex shrink-0 items-center gap-1">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<BaseIconButton
v-if="account.authenticationKind !== 'api_key'"
label="预测周/月额度"
size="sm"
class="h-5 w-5"
aria-haspopup="dialog"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
</div>
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<div class="ml-auto flex items-baseline gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
@@ -134,11 +116,4 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
</div>
</section>
<AccountQuotaForecastModal
v-if="account.authenticationKind !== 'api_key'"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
+1 -1
View File
@@ -37,7 +37,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '套餐', kind: 'status', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
{ key: 'usage', label: '用量', kind: 'custom', size: '2xl', sortable: true },
{ key: 'groups', label: '账号分组', kind: 'status' },
{
+2 -4
View File
@@ -344,13 +344,11 @@ const {
<AccountQuotaPanel
:account="row"
:refreshing="refreshingQuotaAccountIds.has(row.id)"
@account-updated="void replaceAccount($event)"
@quota-reset="handleQuotaReset"
@refresh-quota="handleRefreshQuota"
/>
<AccountUsagePanel
:account="row"
@account-updated="void replaceAccount($event)"
/>
<AccountUsagePanel :account="row" />
</div>
</template>
</BaseTable>
@@ -46,7 +46,7 @@ async function resetBudget() {
<BaseModal
v-model="open"
title="重置已用额度"
description="清零所选周期的已用金额,原重置时间保持不变"
description="清零所选周期的已用金额,下次使用时重新确定重置日期"
size="sm"
:dismissible="!loading"
>
@@ -30,7 +30,7 @@ const { accounts, loading, error, pagination, search, setPage, setPageSize, remo
const columns = defineTableColumns<OutboundProxyAccount>([
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'plan', label: '订阅', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
])
@@ -6,6 +6,7 @@ import { BaseIconButton, BaseInput, BaseTable, BaseTableColumnSettings, BaseTabl
import { Eye, RefreshCw, Search } from '@lucide/vue'
import { shallowRef, toRef } from 'vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { opsErrorSummary } from '../utils/opsErrorPresentation'
@@ -152,6 +153,15 @@ function upstreamSendStateText(value: string | null | undefined) {
:authentication-kind="row.authenticationKind"
/>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #message="{ row }">
<div class="min-w-0 py-0.5" :title="row.message || opsErrorSummary(row)">
<div class="flex min-w-0 items-center gap-2">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { TableColumnSize } from '@codex-proxy/ui'
import type { getUsageRecordInsightsDiagnostics } from '@/api'
import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } from '@codex-proxy/ui'
@@ -37,16 +38,25 @@ const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
const dimensionNameSizes: Record<string, TableColumnSize> = {
model: 'xl',
account: '2xl',
apiKey: 'md',
provider: 'lg',
transport: 'sm',
failureClass: 'xl',
}
const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayItem>([
{
key: 'nameDisplay',
label: '维度',
kind: 'custom',
size: 'xl',
size: dimensionNameSizes[resultDimension.value] ?? 'xl',
},
...(resultDimension.value === 'account'
? defineTableColumns<DiagnosticDisplayItem>([
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
])
: []),
{
@@ -54,22 +64,19 @@ const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayIte
label: '请求',
kind: 'numeric',
size: 'xs',
fixedWidth: true,
},
{
key: 'errorCount',
label: '失败',
kind: 'numeric',
size: 'xs',
fixedWidth: true,
},
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg', fixedWidth: true },
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
{
key: 'estimatedCost',
label: '费用',
kind: 'numeric',
size: 'sm',
fixedWidth: true,
},
]))
+2 -1
View File
@@ -14,7 +14,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
kind: 'identity',
size: '3xl',
},
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{
key: 'provider',
label: '平台/类型',
@@ -40,6 +40,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号', hideable: false },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'message', label: '错误', kind: 'custom', size: '4xl', hideable: false },
{ key: 'upstreamSendState', label: '发送状态', kind: 'custom', size: 'xl' },
+11 -11
View File
@@ -1,19 +1,19 @@
# v3.18.2
# v3.18.3
## 问题修复
- 修复热点诊断在分组超过 100 项时请求占比偏高的问题,分母使用筛选范围内全部匹配请求;重试率按发生重试的请求数计算,同一请求多次重试只计一次
- 系统更新弹窗点击后立即打开,加载期间显示骨架屏;修复关闭时发布说明提前消失、面板高度跳变的问题
- 重置 API 密钥额度后,清零所选周期用量并清除原重置时间,下次使用时重新确定重置日期;保留额度上限、密钥和历史费用记录
- 重置前完成但延迟落盘的费用不会重新扣入已重置周期,也不会提前开启新窗口;重置后完成的在途请求仍正常计费
- 修复出站 HTTP/2 请求重复携带 Host 信息导致部分服务拒绝请求的问题,覆盖插件来源下载;保留 HTTP 正向代理所需的原始 Host
## 计费与管理界面
## 管理界面
- 补充 GPT-6.1 Sol 内置计费规则,覆盖长上下文、服务档位、缓存与搜索费用
- 热点诊断的账号维度和使用记录增加当前套餐列,调整列宽、对齐及套餐徽标对比度,减少诊断卡片空白
- 精简额度预测弹窗,移除剩余额度行,周期标签统一为“周额度”和“月额度”;平台与套餐放到邮箱下方,说明图标移到更新时间后
- API 密钥前缀超出列宽时直接裁切,保留脱敏显示与复制按钮
- 将周/月额度预测入口移到账号额度面板,方便与当前额度一起查看
- 错误日志增加账号当前订阅列,账号、代理关联账号和使用记录等页面统一使用“订阅”标签
- 按热点诊断维度调整列宽,改善账号信息与请求、失败、性能、费用列的空间分配
## 升级说明
- 额度预测接口不再返回 `remainingTokens`、`remainingUsd` 及对应的 `*Display` 字段;直接使用该接口的客户端需同步调整,预计总量的计算规则保持不变
- 套餐列展示账号当前套餐,不是请求发生时的套餐快照;月额度缺少对应上游窗口时仍按 30 天折算,并保留折算提示
- v3.18.0 的更新清单解析限制仍适用,需使用完整平台发行包或容器镜像升级;二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
- 本次不新增或修改数据库迁移
- 插件的预算重置接口与管理员操作保持一致,下次使用再计时;依赖重置后保留原截止时间的插件需调整调用逻辑,结果未知时不能盲目重复重置
- 错误日志的订阅列展示账号当前订阅,不是请求发生时的历史快照
+1 -1
View File
@@ -1 +1 @@
version: "3.18.2"
version: "3.18.3"