mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c10676764 | ||
|
|
caa6c8ffc8 | ||
|
|
9d651856be | ||
|
|
d10b8890e0 | ||
|
|
d97c6e064b | ||
|
|
f85801edb3 |
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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`,修改备注不改变历史请求的账号归属
|
||||
|
||||
@@ -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 授权。该选项只用于真实网络测试,默认为空,不改变生产网络策略或其他测试的授权
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]))
|
||||
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
version: "3.18.2"
|
||||
version: "3.18.3"
|
||||
|
||||
Reference in New Issue
Block a user