mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 00:05:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d8a3f873 | ||
|
|
ac6033f6fa | ||
|
|
cef9f0b21e | ||
|
|
591f95cc18 | ||
|
|
99ba5b1120 |
@@ -56,7 +56,7 @@ jobs:
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
|
||||
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
|
||||
|
||||
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 用第一人称“我”,不用“我们”“本助手”代称自己
|
||||
- 普通回复直接回应问题,不加问候语或称呼;向对方提问时称呼“佬”,同一条回复不重复称呼
|
||||
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
|
||||
Generated
+1
@@ -1915,6 +1915,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"redis",
|
||||
"secrecy",
|
||||
"serde",
|
||||
|
||||
@@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
)
|
||||
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
|
||||
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
|
||||
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
|
||||
.env(
|
||||
"CPR_DATABASE_PASSWORD",
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh",
|
||||
)
|
||||
.env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh")
|
||||
.status()
|
||||
.expect("run isolated environment override test");
|
||||
assert!(status.success());
|
||||
@@ -282,13 +285,53 @@ fn config_loader_should_reject_embedded_database_password() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_non_hex_postgres_password() {
|
||||
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
|
||||
fn config_loader_should_accept_managed_store_passwords() {
|
||||
for password in [
|
||||
"RdsTest9!abcdefGh".to_owned(),
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(),
|
||||
POSTGRES_PASSWORD.to_owned(),
|
||||
"x".repeat(128),
|
||||
"Managed@:/?#%&+Password9!".to_owned(),
|
||||
"数据库密码".repeat(4),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("managed store password");
|
||||
assert!(!format!("{config:?}").contains(&password));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_wrong_length_redis_password() {
|
||||
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
|
||||
fn config_loader_should_reject_invalid_store_passwords() {
|
||||
for password in [
|
||||
String::new(),
|
||||
"x".repeat(15),
|
||||
"x".repeat(129),
|
||||
" ".repeat(32),
|
||||
"\u{3000}".repeat(6),
|
||||
"数据库密码".repeat(9),
|
||||
"RdsTest9!abcdefGh\u{0000}".to_owned(),
|
||||
"RdsTest9!abcdefGh\n".to_owned(),
|
||||
"RdsTest9!abcdefGh\t".to_owned(),
|
||||
"RdsTest9!abcdefGh\u{007f}".to_owned(),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_embedded_redis_password() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"redis://127.0.0.1:6379/",
|
||||
"redis://default:embedded@127.0.0.1:6379/",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -722,10 +722,6 @@ pub struct UsageListRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -803,10 +799,6 @@ pub struct UsageRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
|
||||
@@ -1390,10 +1390,6 @@ fn total_record(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(100),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
|
||||
@@ -304,10 +304,6 @@ pub(crate) fn usage_list_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
first_token_latency_ms: record.first_token_ms,
|
||||
latency_ms: record.latency_ms,
|
||||
@@ -411,10 +407,6 @@ pub(crate) fn usage_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
attempt_index: None,
|
||||
attempt_count: u64::from(record.attempt_count),
|
||||
|
||||
@@ -230,14 +230,6 @@ pub struct UsageLatencyDetailsView {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示
|
||||
|
||||
@@ -213,14 +213,6 @@ struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
@@ -260,10 +252,6 @@ fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> Rec
|
||||
upstream_response_ms: value.upstream_response_ms,
|
||||
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: value.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
|
||||
@@ -58,7 +58,8 @@ fn decode_response_create_inner(
|
||||
else {
|
||||
return Err(ResponseCreateFrameError::ExpectedObject);
|
||||
};
|
||||
match body.remove("type") {
|
||||
// 保持剩余字段顺序,避免末尾元数据交换到模型等路由字段之前
|
||||
match body.shift_remove("type") {
|
||||
Some(Value::String(message_type)) if message_type == "response.create" => {}
|
||||
_ => return Err(ResponseCreateFrameError::UnsupportedType),
|
||||
}
|
||||
|
||||
@@ -288,10 +288,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
record.upstream_response_ms = Some(1_000);
|
||||
record.upstream_api_overhead_ms = Some(120.25);
|
||||
record.upstream_engine_ms = Some(6400.0);
|
||||
record.upstream_engine_iapi_ttft_ms = Some(650.5);
|
||||
record.upstream_engine_service_ttft_ms = Some(720.25);
|
||||
record.upstream_engine_iapi_tbt_ms = Some(18.45);
|
||||
record.upstream_engine_service_tbt_ms = Some(20.12);
|
||||
fixture
|
||||
.usage_detail
|
||||
.lock()
|
||||
@@ -362,22 +358,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
value["data"]["latencyDetails"]["upstreamEngineMs"],
|
||||
json!(6400.0)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTtftMs"],
|
||||
json!(650.5)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTtftMs"],
|
||||
json!(720.25)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
json!(18.45)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTbtMs"],
|
||||
json!(20.12)
|
||||
);
|
||||
assert!(value["data"].get("firstTokenMs").is_none());
|
||||
assert!(value["data"].get("firstTokenLatencyMsDisplay").is_none());
|
||||
assert!(
|
||||
@@ -734,10 +714,6 @@ pub(super) fn usage_record_with_account(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: None,
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
@@ -877,10 +853,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
upstream_response_ms: Some(2_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(31),
|
||||
admission_decision_ms: Some(1),
|
||||
account_selection_wait_ms: Some(2),
|
||||
|
||||
@@ -140,11 +140,7 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_processing_ms: None,
|
||||
upstream_response_ms: Some(1_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(1250),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
|
||||
@@ -320,7 +320,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
&record["latencyDetails"],
|
||||
&[
|
||||
"upstreamResponseMs",
|
||||
"upstreamEngineIapiTbtMs",
|
||||
"upstreamEngineMs",
|
||||
"firstEventMs",
|
||||
"firstReasoningMs",
|
||||
"firstTextMs",
|
||||
@@ -328,10 +328,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["firstEventMs"], 100);
|
||||
assert_eq!(record["latencyDetails"]["upstreamResponseMs"], 1000);
|
||||
assert_eq!(
|
||||
record["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["upstreamEngineMs"], 2.450638);
|
||||
assert!(record["statusCode"].is_null());
|
||||
} else {
|
||||
assert_eq!(record["statusCode"], 502);
|
||||
|
||||
@@ -8,6 +8,26 @@ use serde_json::json;
|
||||
|
||||
use super::decode_response_create;
|
||||
|
||||
#[test]
|
||||
fn response_create_should_preserve_field_order_when_removing_frame_type() {
|
||||
let expected = r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#;
|
||||
for payload in [
|
||||
r#"{"type":"response.create","model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"type":"response.create","input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"},"type":"response.create"}"#,
|
||||
] {
|
||||
let decoded = decode_response_create(payload).expect("decode response.create");
|
||||
let body = decoded
|
||||
.operation()
|
||||
.middleware_body()
|
||||
.expect("encode transparent body");
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&body).expect("UTF-8 JSON body"),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_retry_identity_should_follow_each_frame_instead_of_handshake_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -955,10 +955,6 @@ pub struct ModelRequestTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -807,10 +807,6 @@ impl ResponseObservation {
|
||||
self.timings.upstream_response_ms = None;
|
||||
self.timings.upstream_api_overhead_ms = None;
|
||||
self.timings.upstream_engine_ms = None;
|
||||
self.timings.upstream_engine_iapi_ttft_ms = None;
|
||||
self.timings.upstream_engine_service_ttft_ms = None;
|
||||
self.timings.upstream_engine_iapi_tbt_ms = None;
|
||||
self.timings.upstream_engine_service_tbt_ms = None;
|
||||
}
|
||||
|
||||
pub(super) fn observe_response(&mut self, observation: &ProviderResponseObservation) {
|
||||
@@ -848,17 +844,5 @@ impl ResponseObservation {
|
||||
if let Some(value) = observed.upstream_engine_ms {
|
||||
self.timings.upstream_engine_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_ttft_ms {
|
||||
self.timings.upstream_engine_iapi_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_ttft_ms {
|
||||
self.timings.upstream_engine_service_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_tbt_ms {
|
||||
self.timings.upstream_engine_iapi_tbt_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_tbt_ms {
|
||||
self.timings.upstream_engine_service_tbt_ms = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,12 +536,8 @@ pub struct ProviderResponseTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
/// 上游排除引擎与客户端工具时间后的 API 耗时
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
/// 上游响应级引擎耗时;以下专项指标保留毫秒小数及各自内部计量层级
|
||||
/// 上游响应级引擎耗时,保留毫秒小数
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// Provider 已筛选的响应观测 JSON
|
||||
|
||||
@@ -505,10 +505,6 @@ struct FinalState {
|
||||
upstream_response_ms: Option<u64>,
|
||||
upstream_api_overhead_ms: Option<f64>,
|
||||
upstream_engine_ms: Option<f64>,
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
provider_metadata_json: Option<String>,
|
||||
cost_source: CostSource,
|
||||
cost_ticks: Option<u128>,
|
||||
@@ -684,12 +680,6 @@ impl ExecutionStore for FakeStore {
|
||||
upstream_response_ms: finalization.timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: finalization.timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: finalization.timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: finalization.timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: finalization
|
||||
.timings
|
||||
.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: finalization.timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: finalization.timings.upstream_engine_service_tbt_ms,
|
||||
provider_metadata_json: finalization.provider_metadata_json,
|
||||
cost_source: finalization.cost.source(),
|
||||
cost_ticks: finalization
|
||||
@@ -2067,10 +2057,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_api_overhead_ms: Some(120.25),
|
||||
upstream_engine_ms: Some(6400.0),
|
||||
upstream_engine_iapi_ttft_ms: Some(650.5),
|
||||
upstream_engine_service_ttft_ms: Some(720.25),
|
||||
upstream_engine_iapi_tbt_ms: Some(18.45),
|
||||
upstream_engine_service_tbt_ms: Some(20.12),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let second_observation = ProviderResponseObservation::new(
|
||||
@@ -2164,10 +2150,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
assert_eq!(finalization.upstream_response_ms, None);
|
||||
assert_eq!(finalization.upstream_api_overhead_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_tbt_ms, None);
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
|
||||
@@ -543,10 +543,6 @@ fn wire_timings(observation: &RequestObservation) -> RequestTimings {
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: timings.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,10 +132,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: None,
|
||||
},
|
||||
client_headers: value
|
||||
|
||||
@@ -817,7 +817,7 @@ async fn completed_observation_contains_terminal_cost_timings_and_failure() {
|
||||
.with_timings(ModelRequestTimings {
|
||||
first_text_ms: Some(12),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(34),
|
||||
..ModelRequestTimings::default()
|
||||
}),
|
||||
@@ -831,10 +831,7 @@ async fn completed_observation_contains_terminal_cost_timings_and_failure() {
|
||||
assert_eq!(observed["usage"]["cost"]["total"]["currency"], "USD");
|
||||
assert_eq!(observed["usage"]["timings"]["first_text_ms"], 12);
|
||||
assert_eq!(observed["usage"]["timings"]["upstream_response_ms"], 7_000);
|
||||
assert_eq!(
|
||||
observed["usage"]["timings"]["upstream_engine_iapi_tbt_ms"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(observed["usage"]["timings"]["upstream_engine_ms"], 2.450638);
|
||||
assert_eq!(observed["usage"]["timings"]["latency_ms"], 34);
|
||||
assert_eq!(observed["usage"]["failure"]["outcome"], "failed");
|
||||
assert_eq!(observed["usage"]["failure"]["attempt_count"], 2);
|
||||
|
||||
@@ -186,7 +186,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
|
||||
.with_timings(ProviderResponseTimings {
|
||||
headers_ms: Some(19),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
@@ -273,7 +273,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
|
||||
);
|
||||
assert_eq!(facts["observation"]["timings"]["headers_ms"], 19);
|
||||
assert_eq!(
|
||||
facts["observation"]["timings"]["upstream_engine_iapi_tbt_ms"],
|
||||
facts["observation"]["timings"]["upstream_engine_ms"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -110,14 +110,6 @@ pub struct RequestTimings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -204,13 +204,13 @@ fn cost_timings_and_failure_keep_unknown_facts_explicit_and_safe() {
|
||||
let timings = RequestTimings {
|
||||
first_text_ms: Some(12),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(34),
|
||||
..RequestTimings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&timings).unwrap(),
|
||||
json!({"first_text_ms":12,"upstream_response_ms":7000,"upstream_engine_iapi_tbt_ms":2.450638,"latency_ms":34})
|
||||
json!({"first_text_ms":12,"upstream_response_ms":7000,"upstream_engine_ms":2.450638,"latency_ms":34})
|
||||
);
|
||||
|
||||
let failure = RequestFailure {
|
||||
|
||||
@@ -37,6 +37,7 @@ url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
percent-encoding = "2.3"
|
||||
tempfile = "3.23"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "process", "time", "test-util"] }
|
||||
|
||||
|
||||
@@ -184,12 +184,17 @@ pub(crate) struct StoreConnectionConfig {
|
||||
impl StoreConnectionConfig {
|
||||
fn validate(&self, field: &'static str) -> StoreResult<()> {
|
||||
require_nonempty("store config", field, &self.url)?;
|
||||
if self.password.len() != 48 || !self.password.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
// 托管存储的具体密码策略由服务端约束,客户端共用长度与控制字符边界
|
||||
if !(16..=128).contains(&self.password.len())
|
||||
|| self.password.chars().any(char::is_control)
|
||||
|| self.password.trim().is_empty()
|
||||
{
|
||||
return Err(StoreError::InvalidData {
|
||||
source: None,
|
||||
entity: "store config",
|
||||
message: format!("{field}.password must be exactly 48 hexadecimal characters"),
|
||||
message: format!(
|
||||
"{field}.password must be 16 to 128 bytes, not blank, without control characters"
|
||||
),
|
||||
});
|
||||
}
|
||||
self.connection_url(field).map(|_| ())
|
||||
@@ -208,7 +213,8 @@ impl StoreConnectionConfig {
|
||||
message: format!("{field}.url must not contain a password"),
|
||||
});
|
||||
}
|
||||
url.set_password(Some(&self.password))
|
||||
// set_password 保留已有百分号序列,先转义字面量 %,避免驱动解码后改变原始密码
|
||||
url.set_password(Some(&self.password.replace('%', "%25")))
|
||||
.map_err(|()| StoreError::InvalidData {
|
||||
source: None,
|
||||
entity: "store config",
|
||||
|
||||
@@ -107,14 +107,6 @@ struct UpstreamTimings {
|
||||
api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
@@ -229,16 +221,9 @@ impl RequestObservation {
|
||||
value.retry_after_ms,
|
||||
])?;
|
||||
// serde_json 会把非有限浮点数写成 null,先拒绝以免把非法指标伪装成缺失
|
||||
for value in [
|
||||
timings.upstream_api_overhead_ms,
|
||||
timings.upstream_engine_ms,
|
||||
timings.upstream_engine_iapi_ttft_ms,
|
||||
timings.upstream_engine_service_ttft_ms,
|
||||
timings.upstream_engine_iapi_tbt_ms,
|
||||
timings.upstream_engine_service_tbt_ms,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
for value in [timings.upstream_api_overhead_ms, timings.upstream_engine_ms]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !value.is_finite() || value < 0.0 || value >= i64::MAX as f64 {
|
||||
return Err(invalid(
|
||||
@@ -263,10 +248,6 @@ impl RequestObservation {
|
||||
response_ms: timings.upstream_response_ms,
|
||||
api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
engine_ms: timings.upstream_engine_ms,
|
||||
engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
}),
|
||||
}),
|
||||
transport: Some(TransportObservation {
|
||||
|
||||
@@ -350,10 +350,6 @@ pub(crate) fn usage_list_record_from_row(
|
||||
upstream_response_ms: optional_unsigned(row, "upstream_response_ms")?,
|
||||
upstream_api_overhead_ms: get(row, "upstream_api_overhead_ms")?,
|
||||
upstream_engine_ms: get(row, "upstream_engine_ms")?,
|
||||
upstream_engine_iapi_ttft_ms: get(row, "upstream_engine_iapi_ttft_ms")?,
|
||||
upstream_engine_service_ttft_ms: get(row, "upstream_engine_service_ttft_ms")?,
|
||||
upstream_engine_iapi_tbt_ms: get(row, "upstream_engine_iapi_tbt_ms")?,
|
||||
upstream_engine_service_tbt_ms: get(row, "upstream_engine_service_tbt_ms")?,
|
||||
latency_ms: optional_unsigned(row, "latency_ms")?,
|
||||
admission_decision_ms: optional_unsigned(row, "admission_decision_ms")?,
|
||||
account_selection_wait_ms: optional_unsigned(row, "account_selection_wait_ms")?,
|
||||
@@ -446,10 +442,6 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
|
||||
upstream_response_ms: optional_unsigned(row, "upstream_response_ms")?,
|
||||
upstream_api_overhead_ms: get(row, "upstream_api_overhead_ms")?,
|
||||
upstream_engine_ms: get(row, "upstream_engine_ms")?,
|
||||
upstream_engine_iapi_ttft_ms: get(row, "upstream_engine_iapi_ttft_ms")?,
|
||||
upstream_engine_service_ttft_ms: get(row, "upstream_engine_service_ttft_ms")?,
|
||||
upstream_engine_iapi_tbt_ms: get(row, "upstream_engine_iapi_tbt_ms")?,
|
||||
upstream_engine_service_tbt_ms: get(row, "upstream_engine_service_tbt_ms")?,
|
||||
latency_ms: optional_unsigned(row, "latency_ms")?,
|
||||
admission_decision_ms: optional_unsigned(row, "admission_decision_ms")?,
|
||||
account_selection_wait_ms: optional_unsigned(row, "account_selection_wait_ms")?,
|
||||
|
||||
@@ -128,7 +128,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
|
||||
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
|
||||
mr.provider_processing_ms, mr.upstream_response_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.upstream_engine_iapi_ttft_ms, mr.upstream_engine_service_ttft_ms, mr.upstream_engine_iapi_tbt_ms, mr.upstream_engine_service_tbt_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
|
||||
host(mr.client_ip) as client_ip, mr.user_agent,
|
||||
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
|
||||
@@ -157,7 +157,7 @@ pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
|
||||
mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
|
||||
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
|
||||
mr.provider_processing_ms, mr.upstream_response_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.upstream_engine_iapi_ttft_ms, mr.upstream_engine_service_ttft_ms, mr.upstream_engine_iapi_tbt_ms, mr.upstream_engine_service_tbt_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
|
||||
host(mr.client_ip) as client_ip,
|
||||
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 验证只读检查 Bundle 不执行迁移且拒绝业务写入
|
||||
//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界
|
||||
|
||||
use gateway_core::{
|
||||
account::{
|
||||
@@ -33,11 +33,6 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
|
||||
database
|
||||
.query_pairs_mut()
|
||||
.append_pair("options", &format!("-csearch_path={schema}"));
|
||||
let config_connection = |mut url: url::Url| {
|
||||
let password = url.password().unwrap().to_owned();
|
||||
url.set_password(None).unwrap();
|
||||
json!({"url":url.as_str(),"password":password})
|
||||
};
|
||||
let mut config: StoreConfig = serde_json::from_value(json!({
|
||||
"database":config_connection(database), "redis":config_connection(url::Url::parse(&redis).unwrap()),
|
||||
})).unwrap();
|
||||
@@ -109,3 +104,136 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_bundle_authenticates_configured_and_redis_acl_credentials() {
|
||||
let (Some(database), Some(redis_url)) = (
|
||||
crate::support::test_env("CPR_TEST_DATABASE_URL"),
|
||||
crate::support::test_env("CPR_TEST_REDIS_URL"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let document = json!({
|
||||
"database": config_connection(url::Url::parse(&database).unwrap()),
|
||||
"redis": config_connection(url::Url::parse(&redis_url).unwrap()),
|
||||
});
|
||||
let mut admin = redis::Client::open(redis_url.as_str())
|
||||
.unwrap()
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.unwrap();
|
||||
let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple());
|
||||
let password = "Managed%41%25%FF@:/?#%&+$'\"\\ secret9!";
|
||||
redis::cmd("ACL")
|
||||
.arg("SETUSER")
|
||||
.arg(&username)
|
||||
.arg("on")
|
||||
.arg(format!(">{password}"))
|
||||
.arg("~*")
|
||||
.arg("&*")
|
||||
.arg("+@all")
|
||||
.query_async::<()>(&mut admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut acl_url = url::Url::parse(&redis_url).unwrap();
|
||||
acl_url.set_username(&username).unwrap();
|
||||
acl_url.set_password(None).unwrap();
|
||||
let mut acl_document = document.clone();
|
||||
acl_document["redis"] = json!({"url": acl_url.as_str(), "password": password});
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut results = Vec::new();
|
||||
// 同时走 Store 的配置解析、URL 构造与真实连接路径,断言前先清理临时 ACL 用户
|
||||
for document in [document, acl_document] {
|
||||
let mut config: StoreConfig = serde_json::from_value(document).unwrap();
|
||||
let result = async {
|
||||
config.resolve_and_validate(directory.path())?;
|
||||
let bundle = gateway_store::initialize_read_only(config).await?;
|
||||
let mut states = Vec::new();
|
||||
for probe in bundle.health_probes() {
|
||||
states.push(probe.check().await);
|
||||
}
|
||||
Ok::<_, gateway_store::StoreError>(states)
|
||||
}
|
||||
.await;
|
||||
results.push(result);
|
||||
}
|
||||
redis::cmd("ACL")
|
||||
.arg("DELUSER")
|
||||
.arg(&username)
|
||||
.query_async::<i64>(&mut admin)
|
||||
.await
|
||||
.unwrap();
|
||||
for result in results {
|
||||
let states = result.expect("Store credentials authenticate without double encoding");
|
||||
assert!(!states.is_empty());
|
||||
assert!(
|
||||
states
|
||||
.iter()
|
||||
.all(|state| *state == gateway_core::health::HealthState::Healthy)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_bundle_authenticates_postgres_password_with_percent_sequences() {
|
||||
let (Some(database), Some(redis_url)) = (
|
||||
crate::support::test_env("CPR_TEST_DATABASE_URL"),
|
||||
crate::support::test_env("CPR_TEST_REDIS_URL"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let admin = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple());
|
||||
let password = "Managed%41%25%FFPassword9!";
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
|
||||
"CREATE ROLE {username} LOGIN PASSWORD '{password}'"
|
||||
)))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut database_url = url::Url::parse(&database).unwrap();
|
||||
database_url.set_username(&username).unwrap();
|
||||
database_url.set_password(None).unwrap();
|
||||
let mut config: StoreConfig = serde_json::from_value(json!({
|
||||
"database": {"url": database_url.as_str(), "password": password},
|
||||
"redis": config_connection(url::Url::parse(&redis_url).unwrap()),
|
||||
}))
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = async {
|
||||
config.resolve_and_validate(directory.path())?;
|
||||
let bundle = gateway_store::initialize_read_only(config).await?;
|
||||
let mut states = Vec::new();
|
||||
for probe in bundle.health_probes() {
|
||||
states.push(probe.check().await);
|
||||
}
|
||||
Ok::<_, gateway_store::StoreError>(states)
|
||||
}
|
||||
.await;
|
||||
// 连接失败时也先清理临时角色,避免回归断言留下测试账号
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP ROLE {username}")))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
let states = result.expect("PostgreSQL password percent sequences remain literal");
|
||||
assert!(!states.is_empty());
|
||||
assert!(
|
||||
states
|
||||
.iter()
|
||||
.all(|state| *state == gateway_core::health::HealthState::Healthy)
|
||||
);
|
||||
}
|
||||
|
||||
fn config_connection(mut url: url::Url) -> serde_json::Value {
|
||||
let password = percent_encoding::percent_decode_str(url.password().unwrap())
|
||||
.decode_utf8()
|
||||
.expect("UTF-8 fixture password")
|
||||
.into_owned();
|
||||
url.set_password(None).unwrap();
|
||||
json!({"url": url.as_str(), "password": password})
|
||||
}
|
||||
|
||||
@@ -2168,10 +2168,6 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
upstream_response_ms: Some(1_000),
|
||||
upstream_api_overhead_ms: Some(120.25),
|
||||
upstream_engine_ms: Some(6400.0),
|
||||
upstream_engine_iapi_ttft_ms: Some(650.5),
|
||||
upstream_engine_service_ttft_ms: Some(720.25),
|
||||
upstream_engine_iapi_tbt_ms: Some(18.45),
|
||||
upstream_engine_service_tbt_ms: Some(20.12),
|
||||
latency_ms: Some(9),
|
||||
};
|
||||
ExecutionStore::finalize_model_request(&store, finalization)
|
||||
@@ -2202,22 +2198,6 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
}
|
||||
assert_eq!(persisted["upstream_api_overhead_ms"].as_f64(), Some(120.25));
|
||||
assert_eq!(persisted["upstream_engine_ms"].as_f64(), Some(6400.0));
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_iapi_ttft_ms"].as_f64(),
|
||||
Some(650.5)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_service_ttft_ms"].as_f64(),
|
||||
Some(720.25)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_iapi_tbt_ms"].as_f64(),
|
||||
Some(18.45)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_service_tbt_ms"].as_f64(),
|
||||
Some(20.12)
|
||||
);
|
||||
let invalid_id = "req_invalid_phase";
|
||||
seed_running_request(&database.pool, invalid_id)
|
||||
.await
|
||||
@@ -2233,7 +2213,7 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
assert_eq!(stored_row(&database.pool, invalid_id).await, before);
|
||||
for invalid_ms in [-1.0, f64::NAN, f64::INFINITY, i64::MAX as f64] {
|
||||
let mut invalid = successful_core_finalization(invalid_id);
|
||||
invalid.timings.upstream_engine_iapi_tbt_ms = Some(invalid_ms);
|
||||
invalid.timings.upstream_engine_ms = Some(invalid_ms);
|
||||
let error = ExecutionStore::finalize_model_request(&store, invalid)
|
||||
.await
|
||||
.expect_err("invalid official timing must not silently become missing");
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac
|
||||
'firstTokenMs', $2::bigint,
|
||||
'latencyMs', $3::bigint),
|
||||
'upstream', coalesce(request_observation_json #> '{timings,upstream}', '{}'::jsonb) || jsonb_build_object(
|
||||
'responseMs', $4::bigint, 'engineIapiTbtMs', 2.450638))))
|
||||
'responseMs', $4::bigint, 'engineMs', 2.450638))))
|
||||
where id = 'req_observe_success'",
|
||||
)
|
||||
.bind(output)
|
||||
@@ -150,8 +150,6 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac
|
||||
detail.request.upstream_response_ms,
|
||||
record.upstream_response_ms
|
||||
);
|
||||
assert_eq!(record.upstream_engine_iapi_tbt_ms, Some(2.450638));
|
||||
assert_eq!(detail.request.upstream_engine_iapi_tbt_ms, Some(2.450638));
|
||||
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let summary = repository
|
||||
|
||||
@@ -776,12 +776,12 @@ fn authorization_url(
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", oauth_client_id)
|
||||
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("code_challenge", challenge.as_str())
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true")
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("originator", AUTHORIZATION_ORIGINATOR)
|
||||
.append_pair("codex_app_version", desktop_version)
|
||||
.append_pair("source_surface_stable_id", &stable_id)
|
||||
|
||||
@@ -73,6 +73,10 @@ const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
|
||||
const PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const QUOTA_RESET_FAST_REFRESH_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
const QUOTA_RESET_FAST_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const QUOTA_RESET_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const EXHAUSTED_QUOTA_RESET_GRACE: Duration = Duration::from_secs(10);
|
||||
const QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
|
||||
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限
|
||||
/// 随机启动延迟已把候选摊到多轮,该上限仅作单轮安全边界
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::BTreeMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::{QuotaEvidence, QuotaState};
|
||||
use gateway_core::account::{QuotaAccessState, QuotaEvidence, QuotaState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -127,11 +127,8 @@ fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<U
|
||||
}
|
||||
|
||||
/// 统一手动刷新和 worker 的恢复规则
|
||||
/// 已耗尽窗口的解除有两条证据路径:
|
||||
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
|
||||
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)
|
||||
/// 后者防止账号被钉在旧
|
||||
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照
|
||||
/// 到期前仍要求窗口滚动且低用量,或同窗口连续两次未触顶
|
||||
/// 已记录的耗尽窗口全部到期后,允许新的明确放行观察解除,兼容用量百分比滞后
|
||||
pub(super) fn reconcile_refresh(
|
||||
current: QuotaState,
|
||||
refreshed: &mut CodexAccountQuotaSnapshot,
|
||||
@@ -139,6 +136,11 @@ pub(super) fn reconcile_refresh(
|
||||
document: &mut Map<String, Value>,
|
||||
) -> Result<QuotaState, CodexCredentialQuotaError> {
|
||||
document.remove(RECOVERY_FIELD);
|
||||
if refreshed.credits_usable() {
|
||||
// 点数独立于套餐窗口,恢复后不能继续携带已失效的窗口恢复进度
|
||||
refreshed.recovery = None;
|
||||
return Ok(QuotaState::allowed(refreshed.observed_at()));
|
||||
}
|
||||
let state = if current.is_exhausted() {
|
||||
current
|
||||
} else {
|
||||
@@ -155,7 +157,18 @@ pub(super) fn reconcile_refresh(
|
||||
if recovery.pending.is_empty() {
|
||||
recovery = QuotaRecovery::new(state, refreshed);
|
||||
}
|
||||
if current.is_exhausted() && recovery.observe(refreshed) {
|
||||
// 只信任本次耗尽之后的边界,防止再次拒绝后复用已经过期的 reset 反复解封
|
||||
// 多个耗尽窗口必须全部到期,未知 reset 仍走原有恢复规则
|
||||
let allowed_after_reset = refreshed.quota().access() == QuotaAccessState::Allowed
|
||||
&& !recovery.pending.is_empty()
|
||||
&& recovery.pending.values().all(|reset| {
|
||||
reset.is_some_and(|reset| {
|
||||
reset.timestamp_micros() > recovery.exhausted_at_micros
|
||||
&& SystemTime::from(reset) <= refreshed.observed_at()
|
||||
})
|
||||
});
|
||||
if current.is_exhausted() && (allowed_after_reset || recovery.observe(refreshed)) {
|
||||
refreshed.recovery = None;
|
||||
return Ok(QuotaState::allowed(refreshed.observed_at()));
|
||||
}
|
||||
let reset_at = recovery
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 请求级额度投影、首查与周期刷新排程
|
||||
|
||||
use super::*;
|
||||
use tokio::time::Instant as TokioInstant;
|
||||
|
||||
impl CodexCredentialQuotaService {
|
||||
/// 批量预热请求级额度投影;持久层或 Provider JSON 异常只退化为未知额度
|
||||
@@ -67,7 +68,7 @@ struct CodexQuotaProjectionState {
|
||||
}
|
||||
|
||||
struct CodexQuotaRefreshAttempt {
|
||||
monotonic_at: Instant,
|
||||
monotonic_at: TokioInstant,
|
||||
wall_at: SystemTime,
|
||||
}
|
||||
|
||||
@@ -230,7 +231,7 @@ impl CodexQuotaSchedulingProjection {
|
||||
.iter()
|
||||
.map(|(account, _)| account.id().clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let refreshed_at = Instant::now();
|
||||
let refreshed_at = TokioInstant::now();
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
@@ -239,12 +240,11 @@ impl CodexQuotaSchedulingProjection {
|
||||
.last_periodic_refresh_at
|
||||
.retain(|account_id, _| candidate_ids.contains(account_id));
|
||||
|
||||
// 已耗尽账号首次立即复核,之后每 30 分钟复核,以发现官方提前重置
|
||||
// reset + 2 分钟额外触发一次复核,给上游重置留出传播时间
|
||||
// 已耗尽账号到达 reset 后短周期复核,其余时间保留 30 分钟兜底
|
||||
// 正常账号仅在非零用量窗口经过宽限期后参与,未更新时复用周期节流
|
||||
let mut reserved = Vec::new();
|
||||
for (account, target_reset) in candidates {
|
||||
if !periodic_quota_refresh_due(&state, account.id(), target_reset, now, refreshed_at) {
|
||||
if !periodic_quota_refresh_due(&state, &account, target_reset, now, refreshed_at) {
|
||||
continue;
|
||||
}
|
||||
state.last_periodic_refresh_at.insert(
|
||||
@@ -364,22 +364,34 @@ fn quota_refresh_candidate(
|
||||
|
||||
fn periodic_quota_refresh_due(
|
||||
state: &CodexQuotaProjectionState,
|
||||
account_id: &ProviderAccountId,
|
||||
account: &ProviderAccount,
|
||||
reset_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
monotonic_now: Instant,
|
||||
monotonic_now: TokioInstant,
|
||||
) -> bool {
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.get(account_id)
|
||||
.is_none_or(|last| {
|
||||
monotonic_now.saturating_duration_since(last.monotonic_at)
|
||||
>= PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL
|
||||
|| reset_at
|
||||
.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
|
||||
// 已在该边界之后复核过时回到周期重试,避免过期 reset 每轮触发
|
||||
.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
|
||||
})
|
||||
let mut interval = PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL;
|
||||
let due_at = if account.quota().is_exhausted() {
|
||||
if let Some(since_reset) = reset_at.and_then(|reset| now.duration_since(reset).ok()) {
|
||||
// 上游边界附近可能仍返回旧额度,首次复核也保留传播余量
|
||||
if since_reset < EXHAUSTED_QUOTA_RESET_GRACE {
|
||||
return false;
|
||||
}
|
||||
interval = if since_reset < QUOTA_RESET_FAST_REFRESH_WINDOW {
|
||||
QUOTA_RESET_FAST_REFRESH_INTERVAL
|
||||
} else {
|
||||
QUOTA_RESET_REFRESH_RETRY_INTERVAL
|
||||
};
|
||||
}
|
||||
reset_at.and_then(|reset| reset.checked_add(EXHAUSTED_QUOTA_RESET_GRACE))
|
||||
} else {
|
||||
reset_at.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
|
||||
};
|
||||
let Some(last) = state.last_periodic_refresh_at.get(account.id()) else {
|
||||
return true;
|
||||
};
|
||||
monotonic_now.saturating_duration_since(last.monotonic_at) >= interval
|
||||
// 跨越 reset 宽限期的首轮复核不受旧间隔阻挡,之后按对应间隔节流
|
||||
|| due_at.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
|
||||
}
|
||||
|
||||
fn insert_projection_entry(
|
||||
|
||||
@@ -515,7 +515,7 @@ impl CodexCredentialQuotaService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 真实限额失败后的异步刷新只补齐展示快照,不允许 usage 快照撤销已确认的失败状态
|
||||
/// 真实限额失败后的异步回读仅允许明确可用的点数恢复访问,套餐状态仍保留失败保护
|
||||
pub(crate) async fn refresh_account_after_failure(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -589,9 +589,10 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if account.quota().is_exhausted()
|
||||
&& matches!(authority, QuotaRefreshAuthority::PreserveAccess)
|
||||
{
|
||||
// 失败后回读只允许明确可用的点数恢复访问,套餐 allowed 仍可能滞后
|
||||
let observe_access =
|
||||
matches!(authority, QuotaRefreshAuthority::ObserveAccess) || snapshot.credits_usable();
|
||||
if account.quota().is_exhausted() && !observe_access {
|
||||
if self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
@@ -615,14 +616,15 @@ impl CodexCredentialQuotaService {
|
||||
.with_observed_at(observed_at),
|
||||
});
|
||||
}
|
||||
let state = match authority {
|
||||
QuotaRefreshAuthority::ObserveAccess => reconcile_refresh(
|
||||
let state = if observe_access {
|
||||
reconcile_refresh(
|
||||
account.quota(),
|
||||
&mut snapshot,
|
||||
previous.as_ref(),
|
||||
&mut object,
|
||||
)?,
|
||||
QuotaRefreshAuthority::PreserveAccess => account.quota(),
|
||||
)?
|
||||
} else {
|
||||
account.quota()
|
||||
};
|
||||
let snapshot = snapshot.with_quota_state(state);
|
||||
if self
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct CodexAccountQuotaSnapshot {
|
||||
observed_at: SystemTime,
|
||||
plan_type: Option<String>,
|
||||
credits: Option<CreditsSnapshot>,
|
||||
credits_usable: bool,
|
||||
fact: CodexQuotaFact,
|
||||
quota: QuotaState,
|
||||
windows: Vec<CodexQuotaWindow>,
|
||||
@@ -144,12 +145,16 @@ impl CodexAccountQuotaSnapshot {
|
||||
self.plan_type.as_deref()
|
||||
}
|
||||
|
||||
/// 点数只用于展示,不改变套餐限额或账号可用性
|
||||
/// 上游明确返回的点数事实;点数可用性独立于套餐窗口
|
||||
#[must_use]
|
||||
pub fn credits(&self) -> Option<&CreditsSnapshot> {
|
||||
self.credits.as_ref()
|
||||
}
|
||||
|
||||
pub(super) const fn credits_usable(&self) -> bool {
|
||||
self.credits_usable
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn fact(&self) -> CodexQuotaFact {
|
||||
self.fact
|
||||
@@ -209,7 +214,7 @@ fn parse_codex_quota_object(
|
||||
for limit in canonical_rate_limits(object)? {
|
||||
aggregate.observe_rate_limit(limit.rate_limit)?;
|
||||
}
|
||||
// credits / spend_control 只证明文档结构已识别,不参与账号级访问结论
|
||||
// 点数与支出控制不换算成套餐剩余百分比,访问结论由账号快照统一判断
|
||||
if let Some(spend_control) = object.get("spend_control") {
|
||||
aggregate.observe_metadata_object(spend_control, "reached")?;
|
||||
}
|
||||
@@ -345,6 +350,12 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
.then_with(|| left.key.cmp(&right.key))
|
||||
});
|
||||
let quota = authoritative_quota_state(&object, &windows, observed_at)?;
|
||||
let credits_usable = has_usable_credits(&object);
|
||||
let quota = if credits_usable {
|
||||
QuotaState::allowed(observed_at)
|
||||
} else {
|
||||
quota
|
||||
};
|
||||
let recovery = object
|
||||
.get(RECOVERY_FIELD)
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
@@ -359,6 +370,7 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
credits: object.get("credits").and_then(parse_credits_from_object),
|
||||
credits_usable,
|
||||
fact,
|
||||
quota,
|
||||
windows,
|
||||
@@ -374,7 +386,27 @@ fn quota_source_order(source: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认 `codex` 桶是唯一可改变账号额度访问结论的 quota 响应字段
|
||||
fn has_usable_credits(usage: &Map<String, Value>) -> bool {
|
||||
let Some(credits) = usage.get("credits") else {
|
||||
return false;
|
||||
};
|
||||
let available = credits.get("has_credits").and_then(Value::as_bool) == Some(true)
|
||||
|| credits.get("unlimited").and_then(Value::as_bool) == Some(true);
|
||||
// 套餐耗尽不阻止点数支出,但不能绕过显式支出上限或工作区限制
|
||||
let spend_reached = usage
|
||||
.get("spend_control")
|
||||
.and_then(|spend| spend.get("reached"))
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true);
|
||||
let other_limit_reached = usage
|
||||
.get("rate_limit_reached_type")
|
||||
.and_then(|reason| reason.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|reason| reason != "rate_limit_reached");
|
||||
available && !spend_reached && !other_limit_reached
|
||||
}
|
||||
|
||||
/// 只有默认 `codex` 桶能提供账号级套餐访问结论,具名桶只保留展示事实
|
||||
fn authoritative_quota_state(
|
||||
usage: &Map<String, Value>,
|
||||
windows: &[CodexQuotaWindow],
|
||||
|
||||
@@ -199,18 +199,6 @@ impl OpenAiResponseObservationState {
|
||||
if metrics.upstream_engine_ms.is_some() {
|
||||
self.timings.upstream_engine_ms = metrics.upstream_engine_ms;
|
||||
}
|
||||
if metrics.upstream_engine_iapi_ttft_ms.is_some() {
|
||||
self.timings.upstream_engine_iapi_ttft_ms = metrics.upstream_engine_iapi_ttft_ms;
|
||||
}
|
||||
if metrics.upstream_engine_service_ttft_ms.is_some() {
|
||||
self.timings.upstream_engine_service_ttft_ms = metrics.upstream_engine_service_ttft_ms;
|
||||
}
|
||||
if metrics.upstream_engine_iapi_tbt_ms.is_some() {
|
||||
self.timings.upstream_engine_iapi_tbt_ms = metrics.upstream_engine_iapi_tbt_ms;
|
||||
}
|
||||
if metrics.upstream_engine_service_tbt_ms.is_some() {
|
||||
self.timings.upstream_engine_service_tbt_ms = metrics.upstream_engine_service_tbt_ms;
|
||||
}
|
||||
self.timings != previous
|
||||
}
|
||||
|
||||
|
||||
@@ -165,12 +165,6 @@ impl CodexBackendClient {
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<HeaderMap> {
|
||||
let mut headers = self.response_headers(profile, request, context)?;
|
||||
if !headers.contains_key("x-responsesapi-include-timing-metrics") {
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-responsesapi-include-timing-metrics"),
|
||||
HeaderValue::from_static("true"),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
HeaderName::from_static("openai-beta"),
|
||||
HeaderValue::from_static("responses_websockets=2026-02-06"),
|
||||
|
||||
@@ -255,10 +255,6 @@ pub(crate) fn response_duration_ms(value: &Value) -> Option<u64> {
|
||||
pub(crate) struct ResponseTimingMetrics {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
impl ResponseTimingMetrics {
|
||||
@@ -287,7 +283,6 @@ impl ResponseTimingMetrics {
|
||||
return Self {
|
||||
upstream_api_overhead_ms: overhead,
|
||||
upstream_engine_ms: milliseconds(path, "engine_wall_ms"),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
// 直接返回的专项指标保留原口径,明确标为整轮累计的值不能归入单次请求
|
||||
@@ -303,16 +298,6 @@ impl ResponseTimingMetrics {
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms",
|
||||
),
|
||||
upstream_engine_ms: milliseconds(metrics, "engine_service_total_ms"),
|
||||
upstream_engine_iapi_ttft_ms: milliseconds(metrics, "engine_iapi_ttft_total_ms"),
|
||||
upstream_engine_service_ttft_ms: milliseconds(metrics, "engine_service_ttft_total_ms"),
|
||||
upstream_engine_iapi_tbt_ms: milliseconds(
|
||||
metrics,
|
||||
"engine_iapi_tbt_across_engine_calls_ms",
|
||||
),
|
||||
upstream_engine_service_tbt_ms: milliseconds(
|
||||
metrics,
|
||||
"engine_service_tbt_across_engine_calls_ms",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,18 +308,6 @@ impl ResponseTimingMetrics {
|
||||
if incoming.upstream_engine_ms.is_some() {
|
||||
self.upstream_engine_ms = incoming.upstream_engine_ms;
|
||||
}
|
||||
if incoming.upstream_engine_iapi_ttft_ms.is_some() {
|
||||
self.upstream_engine_iapi_ttft_ms = incoming.upstream_engine_iapi_ttft_ms;
|
||||
}
|
||||
if incoming.upstream_engine_service_ttft_ms.is_some() {
|
||||
self.upstream_engine_service_ttft_ms = incoming.upstream_engine_service_ttft_ms;
|
||||
}
|
||||
if incoming.upstream_engine_iapi_tbt_ms.is_some() {
|
||||
self.upstream_engine_iapi_tbt_ms = incoming.upstream_engine_iapi_tbt_ms;
|
||||
}
|
||||
if incoming.upstream_engine_service_tbt_ms.is_some() {
|
||||
self.upstream_engine_service_tbt_ms = incoming.upstream_engine_service_tbt_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,12 +265,12 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
|
||||
"response_type",
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"scope",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"state",
|
||||
"scope",
|
||||
"id_token_add_organizations",
|
||||
"codex_cli_simplified_flow",
|
||||
"state",
|
||||
"originator",
|
||||
"codex_app_version",
|
||||
"source_surface_stable_id",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! 点数独立可用性、耗尽恢复和过期查询隔离
|
||||
|
||||
use super::*;
|
||||
|
||||
fn exhausted_usage() -> serde_json::Value {
|
||||
json!({"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000},
|
||||
"secondary_window": {"used_percent": 100, "reset_at": 1_900_604_800}
|
||||
}})
|
||||
}
|
||||
|
||||
async fn mount_usage(server: &MockServer, usage: serde_json::Value) {
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(usage))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_allow_exhausted_subscription_without_bypassing_spend_restrictions() {
|
||||
for (metadata, allowed) in [
|
||||
(
|
||||
json!({"credits": {"has_credits": true, "unlimited": false}}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": false, "unlimited": true}}),
|
||||
true,
|
||||
),
|
||||
(json!({"credits": {"has_credits": true}}), true),
|
||||
(json!({"credits": {"unlimited": true}}), true),
|
||||
(
|
||||
json!({"credits": {"has_credits": false, "unlimited": false, "balance": "500"}}),
|
||||
false,
|
||||
),
|
||||
(json!({"credits": null}), false),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "spend_control": {"reached": true}}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "spend_limit_reached"}}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "rate_limit_reached"}}),
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account_with_enabled(&store, "acct_credits", false).await;
|
||||
let account = store.account("acct_credits").expect("account");
|
||||
persist_credential_state(&store, &account, CredentialState::Expired).await;
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut usage = exhausted_usage();
|
||||
usage
|
||||
.as_object_mut()
|
||||
.expect("usage")
|
||||
.extend(metadata.as_object().expect("metadata").clone());
|
||||
mount_usage(&server, usage).await;
|
||||
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh credits");
|
||||
assert_eq!(
|
||||
snapshot.quota().access() == QuotaAccessState::Allowed,
|
||||
allowed,
|
||||
"{metadata}"
|
||||
);
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
let current = store.account("acct_credits").expect("current account");
|
||||
assert_eq!(current.quota().is_exhausted(), !allowed, "{metadata}");
|
||||
assert!(!current.enabled());
|
||||
assert_eq!(current.credential_state(), CredentialState::Expired);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_refresh_clears_old_recovery_and_locks_again_only_when_spent() {
|
||||
for periodic in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_credits_recovery").await;
|
||||
let account = store.account("acct_credits_recovery").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
mount_usage(&server, exhausted_usage()).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhaustion");
|
||||
assert!(
|
||||
store
|
||||
.quota_json("acct_credits_recovery")
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let mut usage = exhausted_usage();
|
||||
usage["credits"] =
|
||||
json!({"has_credits": !periodic, "unlimited": periodic, "balance": "20"});
|
||||
mount_usage(&server, usage.clone()).await;
|
||||
if periodic {
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("periodic recovery")
|
||||
.updated,
|
||||
1
|
||||
);
|
||||
} else {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual recovery");
|
||||
}
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("persisted snapshot")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.quota().access(), QuotaAccessState::Allowed);
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
assert_eq!(
|
||||
snapshot.credits().expect("credits").balance.as_deref(),
|
||||
Some("20")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.quota_json("acct_credits_recovery")
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
usage["credits"] = json!({"has_credits": false, "unlimited": false, "balance": "0"});
|
||||
mount_usage(&server, usage).await;
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("spent credits");
|
||||
assert!(snapshot.quota().is_exhausted());
|
||||
assert!(
|
||||
store
|
||||
.account("acct_credits_recovery")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_query_started_before_new_exhaustion_cannot_restore_access() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_stale_credits").await;
|
||||
let account = store.account("acct_stale_credits").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut usage = exhausted_usage();
|
||||
usage["credits"] = json!({"has_credits": true, "unlimited": false});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_json(usage),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (result, ()) = tokio::join!(service.refresh_account(account.id()), async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests")
|
||||
.is_empty()
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("query started");
|
||||
persist_quota_state(
|
||||
&store,
|
||||
&account,
|
||||
QuotaState::exhausted(QuotaEvidence::UsageLimitReached, SystemTime::now(), None),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
result.expect("old display observation can persist independently of newer access state");
|
||||
assert!(
|
||||
store
|
||||
.account("acct_stale_credits")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("persisted quota")
|
||||
.expect("snapshot")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! OpenAI 额度事实边界与展示快照回归
|
||||
|
||||
mod capacity_freeze;
|
||||
mod credits;
|
||||
mod initial_sync;
|
||||
mod recovery;
|
||||
mod refresh_timing;
|
||||
mod reset_credits;
|
||||
mod reset_recovery;
|
||||
mod scheduling;
|
||||
mod slots;
|
||||
mod snapshot;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 通过 quota 服务验证周期复核与 reset 宽限期,不依赖内部调度状态
|
||||
//! 通过 quota 服务验证耗尽复核节奏、恢复与正常窗口宽限期
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -17,8 +17,17 @@ fn reset_grace(reset: i64) -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs((reset + 120) as u64)
|
||||
}
|
||||
|
||||
async fn advance_clock(now: &mut SystemTime, seconds: u64) {
|
||||
let duration = Duration::from_secs(seconds);
|
||||
// 只在推进时暂停 Tokio 时钟,HTTP 请求期间保持真实计时
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(duration).await;
|
||||
tokio::time::resume();
|
||||
*now += duration;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window() {
|
||||
async fn exhausted_reset_bypasses_periodic_throttle_and_rechecks_each_pending_window() {
|
||||
let mut now = SystemTime::now();
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_quota_timing").await;
|
||||
@@ -30,8 +39,8 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
- 115;
|
||||
let week_reset = short_reset + 3;
|
||||
+ 5;
|
||||
let week_reset = short_reset + 30;
|
||||
let usage = |short_used, short_reset, week_used, week_reset| {
|
||||
json!({"rate_limit": {
|
||||
"allowed": false,
|
||||
@@ -55,20 +64,28 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
let requests = server.received_requests().await.expect("requests").len();
|
||||
|
||||
assert!(
|
||||
now < reset_grace(short_reset),
|
||||
"fixture must precede grace deadline"
|
||||
now < SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64),
|
||||
"fixture must precede reset"
|
||||
);
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("before grace deadline");
|
||||
service.synchronize_at(now).await.expect("before reset");
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
requests
|
||||
);
|
||||
|
||||
mount_usage(&server, usage(0, short_reset + 18_000, 100, week_reset)).await;
|
||||
now = reset_grace(short_reset);
|
||||
for delay in [0, 9] {
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64 + delay);
|
||||
service.synchronize_at(now).await.expect("reset grace");
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64 + 10);
|
||||
let partial = service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
@@ -85,11 +102,11 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("next scan before weekly grace deadline");
|
||||
.expect("next scan before weekly reset");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
|
||||
// 周窗口的到期复核仍未恢复时,也不能每轮扫描重复请求
|
||||
now = reset_grace(week_reset);
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(week_reset as u64 + 10);
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
@@ -127,19 +144,139 @@ async fn periodic_checks_continue_when_reset_is_unknown_or_far_in_the_future() {
|
||||
.expect("seed exhaustion");
|
||||
mount_usage(&server, usage).await;
|
||||
|
||||
let mut now = SystemTime::now();
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize()
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("periodic check")
|
||||
.exhausted,
|
||||
1
|
||||
);
|
||||
service.synchronize().await.expect("throttled repeat check");
|
||||
advance_clock(&mut now, 29 * 60).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("throttled repeat check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
advance_clock(&mut now, 60).await;
|
||||
service.synchronize_at(now).await.expect("periodic retry");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_reset_refreshes_quickly_then_backs_off_and_recovers_from_usage() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_reset_retry").await;
|
||||
let account = store.account("acct_reset_retry").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let reset = Utc::now().timestamp();
|
||||
let mut now = SystemTime::UNIX_EPOCH + Duration::from_secs(reset as u64);
|
||||
let usage = |allowed, used, reset| {
|
||||
json!({"rate_limit": {
|
||||
"allowed": allowed,
|
||||
"primary_window": {"used_percent": used, "reset_at": reset}
|
||||
}})
|
||||
};
|
||||
mount_usage(&server, usage(false, 100, reset)).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhaustion");
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("reset grace without periodic history");
|
||||
advance_clock(&mut now, 9).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("still in reset grace");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
advance_clock(&mut now, 1).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("initial reset check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 2);
|
||||
|
||||
// 本次拒绝发生在 reset 之后,滞后的 allowed 不能复用旧边界解封
|
||||
mount_usage(&server, usage(true, 100, reset)).await;
|
||||
for (elapsed, requests) in [
|
||||
(29, 0),
|
||||
(1, 1),
|
||||
(29, 1),
|
||||
(1, 2),
|
||||
(229, 3),
|
||||
(1, 3),
|
||||
(298, 3),
|
||||
(1, 4),
|
||||
] {
|
||||
advance_clock(&mut now, elapsed).await;
|
||||
service.synchronize_at(now).await.expect("scheduled check");
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
requests
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.account("acct_reset_retry")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
|
||||
// 查询失败也占用一次尝试,避免网络故障时取消节流
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
advance_clock(&mut now, 300).await;
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("failed check")
|
||||
.transient,
|
||||
1
|
||||
);
|
||||
advance_clock(&mut now, 299).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("throttled failed check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
|
||||
mount_usage(&server, usage(true, 0, reset + 18_000)).await;
|
||||
advance_clock(&mut now, 1).await;
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("recovery check")
|
||||
.updated,
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.account("acct_reset_retry")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
advance_clock(&mut now, 300).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("recovered account leaves retry schedule");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_account_with_expired_window_synchronizes_at_reset_grace() {
|
||||
let mut now = SystemTime::now();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 到期后的明确放行恢复,保留到期前、多窗口与再次拒绝的保护
|
||||
|
||||
use gateway_core::account::{OpaqueProviderData, QuotaObservation};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn usage(now: i64, resets: &[Option<i64>], allowed: Option<bool>) -> serde_json::Value {
|
||||
let mut value = json!({"rate_limit": {}});
|
||||
if let Some(allowed) = allowed {
|
||||
value["rate_limit"]["allowed"] = json!(allowed);
|
||||
}
|
||||
for ((role, seconds), reset) in [("primary_window", 18_000), ("secondary_window", 604_800)]
|
||||
.into_iter()
|
||||
.zip(resets)
|
||||
{
|
||||
let mut window = json!({"used_percent": 100, "limit_window_seconds": seconds});
|
||||
if let Some(offset) = reset {
|
||||
window["reset_at"] = json!(now + offset);
|
||||
}
|
||||
value["rate_limit"][role] = window;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_refresh_recovers_full_windows_only_after_the_confirmed_resets() {
|
||||
for (name, resets, failure_offset, allowed, recovered) in [
|
||||
("before_reset", vec![Some(3600)], -120, Some(true), false),
|
||||
("after_reset", vec![Some(-60)], -120, Some(true), true),
|
||||
("still_denied", vec![Some(-60)], -120, Some(false), false),
|
||||
("unknown_access", vec![Some(-60)], -120, None, false),
|
||||
("unknown_reset", vec![None], -120, Some(true), false),
|
||||
("new_failure", vec![Some(-60)], -30, Some(true), false),
|
||||
(
|
||||
"weekly_not_due",
|
||||
vec![Some(-60), Some(3600)],
|
||||
-120,
|
||||
Some(true),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"both_due",
|
||||
vec![Some(-90), Some(-60)],
|
||||
-120,
|
||||
Some(true),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"weekly_unknown",
|
||||
vec![Some(-60), None],
|
||||
-120,
|
||||
Some(true),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
for worker in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = format!("acct_reset_{name}_{worker}");
|
||||
create_account(&store, &account_id).await;
|
||||
let account = store.account(&account_id).expect("account");
|
||||
let now = Utc::now().timestamp();
|
||||
let observed_at =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs((now + failure_offset) as u64);
|
||||
let reset_at = resets
|
||||
.iter()
|
||||
.filter_map(|reset| *reset)
|
||||
.min()
|
||||
.map(|offset| SystemTime::UNIX_EPOCH + Duration::from_secs((now + offset) as u64));
|
||||
let old_usage = usage(now, &resets, Some(false));
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
plan_type: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: OpaqueProviderData::new(old_usage.as_object().expect("usage").clone()),
|
||||
observed_at,
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
observed_at,
|
||||
reset_at,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist confirmed exhaustion before refresh");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(usage(now, &resets, allowed)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
if worker {
|
||||
let summary = service.synchronize().await.expect("worker refresh");
|
||||
assert_eq!(summary.updated, u64::from(recovered), "{name}");
|
||||
} else {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual refresh");
|
||||
}
|
||||
let refreshed = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read")
|
||||
.expect("snapshot");
|
||||
assert_eq!(
|
||||
refreshed.quota().is_exhausted(),
|
||||
!recovered,
|
||||
"{name}, worker={worker}"
|
||||
);
|
||||
assert!(
|
||||
refreshed
|
||||
.windows()
|
||||
.iter()
|
||||
.all(|window| window.used_percent() == Some(100.0))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(&account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted(),
|
||||
!recovered
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.quota_json(&account_id)
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_none(),
|
||||
recovered
|
||||
);
|
||||
|
||||
if recovered {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("allowed remains allowed");
|
||||
assert!(
|
||||
!store
|
||||
.account(&account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
// 新拒绝不能沿用刚刚解除时的旧 reset,即使查询仍然 allowed=true
|
||||
persist_quota_state(&store, &account, exhausted_quota(reset_at)).await;
|
||||
for _ in 0..2 {
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh after new refusal");
|
||||
assert!(snapshot.quota().is_exhausted(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8421,6 +8421,82 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_failure_recovers_with_credits_without_replaying_the_failed_request() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
},
|
||||
"credits": {"has_credits": true, "unlimited": false, "balance": "20"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
|
||||
"error": {"type": "usage_limit_reached", "message": "usage limit reached", "resets_at": 1_900_000_000}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_usage_limit_credits", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
let failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("quota failure must reach the caller"),
|
||||
}
|
||||
};
|
||||
assert_eq!(failure.kind(), ProviderErrorKind::QuotaExhausted);
|
||||
assert!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
while store
|
||||
.account(account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("credits restore access after background refresh");
|
||||
assert!(stream.next().await.is_none());
|
||||
let account = store.account(account_id).expect("recovered account");
|
||||
assert_eq!(account.quota().access(), QuotaAccessState::Allowed);
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
let quota = store.quota_json(account_id).expect("persisted credits");
|
||||
assert_eq!(quota.pointer("/credits/has_credits"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
quota.pointer("/rate_limits_by_limit_id/codex/primary_window/used_percent"),
|
||||
Some(&json!(100))
|
||||
);
|
||||
assert!(quota.get("_quota_recovery").is_none());
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_retry() {
|
||||
for use_websocket in [false, true] {
|
||||
|
||||
@@ -66,9 +66,10 @@ async fn observe_timing_events(websocket: bool, events: Vec<Value>) -> ProviderR
|
||||
let mut ws = crate::transport::accept_codex_test_websocket_with(
|
||||
socket,
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| {
|
||||
assert_eq!(
|
||||
request.headers()["x-responsesapi-include-timing-metrics"],
|
||||
"true"
|
||||
assert!(
|
||||
!request
|
||||
.headers()
|
||||
.contains_key("x-responsesapi-include-timing-metrics")
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"sec-websocket-extensions",
|
||||
@@ -125,19 +126,11 @@ async fn official_response_duration_is_observed_independently_of_the_request_clo
|
||||
let metrics = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":120.25,
|
||||
"engine_service_total_ms":6400.0,
|
||||
"engine_iapi_ttft_total_ms":650.5,
|
||||
"engine_service_ttft_total_ms":720.25,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":18.45,
|
||||
"engine_service_tbt_across_engine_calls_ms":20.12
|
||||
}});
|
||||
let wrong_id = json!({"type":"responsesapi.websocket_timing","response_id":"resp_other","timing_metrics":{"engine_service_total_ms":999.0}});
|
||||
let invalid = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":null,
|
||||
"engine_service_total_ms":"100",
|
||||
"engine_iapi_ttft_total_ms":-1,
|
||||
"engine_service_ttft_total_ms":1e99,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":{},
|
||||
"engine_service_tbt_across_engine_calls_ms":[]
|
||||
}});
|
||||
let stale = json!({"type":"responsesapi.websocket_timing","timing_metrics":{"engine_service_total_ms":999.0}});
|
||||
let events = [stale, created, metrics, wrong_id, invalid, completed];
|
||||
@@ -145,10 +138,6 @@ async fn official_response_duration_is_observed_independently_of_the_request_clo
|
||||
assert_eq!(timings.upstream_response_ms, Some(7_000));
|
||||
assert_eq!(timings.upstream_api_overhead_ms, Some(120.25));
|
||||
assert_eq!(timings.upstream_engine_ms, Some(6400.0));
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, Some(650.5));
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, Some(720.25));
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, Some(18.45));
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, Some(20.12));
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
assert!(timings.first_event_ms.is_some_and(|value| value >= 20_000));
|
||||
}
|
||||
@@ -197,7 +186,6 @@ async fn response_critical_path_is_observed_without_logical_turn_totals_on_both_
|
||||
let mut metrics = response_critical_path_event();
|
||||
// 同帧即使携带整轮专项值,也不能与响应级 critical path 混算
|
||||
metrics["timing_metrics"]["engine_service_total_ms"] = json!(25_000);
|
||||
metrics["timing_metrics"]["engine_iapi_ttft_total_ms"] = json!(500);
|
||||
let mut events = critical_path_response_events(metrics);
|
||||
let mut wrong_id = response_critical_path_event();
|
||||
wrong_id["timing_metrics"]["response_id"] = json!("resp_other");
|
||||
@@ -208,10 +196,6 @@ async fn response_critical_path_is_observed_without_logical_turn_totals_on_both_
|
||||
assert!((timings.upstream_api_overhead_ms.unwrap() - 81.409594).abs() < 0.000001);
|
||||
assert_eq!(timings.upstream_response_ms, Some(7000));
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,12 +236,10 @@ async fn critical_path_rejects_mismatched_identity_and_incomplete_or_cumulative_
|
||||
"timing_scope":"logical_turn",
|
||||
"engine_service_total_ms":25_000,
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":500,
|
||||
"engine_iapi_ttft_total_ms":200
|
||||
}});
|
||||
let timings = observe_timing_events(false, critical_path_response_events(metrics)).await;
|
||||
assert_eq!(timings.upstream_engine_ms, None);
|
||||
assert_eq!(timings.upstream_api_overhead_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -288,10 +270,6 @@ async fn timing_frames_outside_the_active_response_do_not_enter_request_metrics(
|
||||
let timing = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":0,
|
||||
"engine_service_total_ms":450,
|
||||
"engine_iapi_ttft_total_ms":211,
|
||||
"engine_service_ttft_total_ms":233,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":2.450638,
|
||||
"engine_service_tbt_across_engine_calls_ms":5.267279
|
||||
}});
|
||||
let created = json!({"type":"response.created","response":{"id":"resp_boundary"}});
|
||||
let completed = json!({"type":"response.completed","response":{"id":"resp_boundary","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}});
|
||||
@@ -311,10 +289,6 @@ async fn timing_frames_outside_the_active_response_do_not_enter_request_metrics(
|
||||
let timings = finish_timings(&mut stream, ProviderResponseTimings::default()).await;
|
||||
assert_eq!(timings.upstream_api_overhead_ms, None);
|
||||
assert_eq!(timings.upstream_engine_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, None);
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
server.await.unwrap();
|
||||
}
|
||||
@@ -395,9 +369,10 @@ async fn structural_frames_start_ttft_before_content_on_http_and_websocket() {
|
||||
socket,
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
response| {
|
||||
assert_eq!(
|
||||
request.headers()["x-responsesapi-include-timing-metrics"],
|
||||
"true"
|
||||
assert!(
|
||||
!request
|
||||
.headers()
|
||||
.contains_key("x-responsesapi-include-timing-metrics")
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"sec-websocket-extensions",
|
||||
|
||||
@@ -430,7 +430,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
|
||||
async fn compare_and_swap_quota(
|
||||
&self,
|
||||
observation: QuotaObservation,
|
||||
mut observation: QuotaObservation,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
@@ -446,7 +446,18 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
{
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let quota = observation.state;
|
||||
// 与 PostgreSQL 一致,展示快照与访问事实分别按各自观察时间隔离
|
||||
let current = stored.account.quota();
|
||||
let quota = if observation
|
||||
.state
|
||||
.observed_at()
|
||||
.is_some_and(|observed| current.observed_at().is_none_or(|time| time <= observed))
|
||||
{
|
||||
observation.state
|
||||
} else {
|
||||
current
|
||||
};
|
||||
observation.state = quota;
|
||||
let mut replacement = AccountRebuild::preserving(&stored.account).with_quota(quota);
|
||||
if let Some(plan_type) = &observation.plan_type {
|
||||
replacement.profile = Some((
|
||||
|
||||
@@ -110,8 +110,7 @@ async fn official_websocket_wire_fingerprint_should_remain_aligned() {
|
||||
opening.lines().next(),
|
||||
Some("GET /backend-api/codex/responses HTTP/1.1")
|
||||
);
|
||||
// 固定身份字段沿用官方画像,专项计时头按官方客户端在 openai-beta 后插入
|
||||
// HeaderMap 序列化会随新增头调整迭代顺序,锁定启用计时后的完整 opening
|
||||
// 固定身份字段沿用官方画像,锁定默认不请求专项计时的完整 opening
|
||||
assert_eq!(
|
||||
read_header_names(&opening),
|
||||
vec![
|
||||
@@ -124,7 +123,7 @@ async fn official_websocket_wire_fingerprint_should_remain_aligned() {
|
||||
"authorization",
|
||||
"user-agent",
|
||||
"originator",
|
||||
"x-responsesapi-include-timing-metrics",
|
||||
"openai-beta",
|
||||
"version",
|
||||
"x-codex-beta-features",
|
||||
"x-client-request-id",
|
||||
@@ -133,13 +132,12 @@ async fn official_websocket_wire_fingerprint_should_remain_aligned() {
|
||||
"x-codex-window-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-codex-routing-hint",
|
||||
"openai-beta",
|
||||
"sec-websocket-extensions",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
read_header_value(&opening, "x-responsesapi-include-timing-metrics"),
|
||||
Some("true"),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
read_header_value(&opening, "sec-websocket-extensions"),
|
||||
|
||||
@@ -48,7 +48,8 @@ export CPR_TEST_REDIS_URL='redis://:<password>@127.0.0.1:6379'
|
||||
PostgreSQL 的临时 schema 测试连接使用异步提交,保留事务可见性、回滚和完整迁移检查
|
||||
需要验证数据库崩溃后的持久性时,使用独立数据库与生产连接配置,不复用这类 fixture
|
||||
|
||||
运行包含 `StoreBundle` 初始化的完整集成测试时,两条测试 URL 都须包含密码,且密码满足 Store
|
||||
启动配置的 48 位十六进制要求;仅能连接数据库并不代表该初始化合同通过。专用服务使用对应测试密码,
|
||||
运行包含 `StoreBundle` 初始化的完整集成测试时,两条测试 URL 都须包含密码,原始密码须满足
|
||||
[Store 密码要求](../../deploy/README.md#手动安装),在测试 URL 中按 URL 规则编码。
|
||||
仅能连接数据库并不代表该初始化合同通过。专用服务使用对应测试密码,
|
||||
并在测试进程中清除 `CPR_DATABASE_URL`、`CPR_REDIS_URL`、`CPR_DATABASE_PASSWORD` 和
|
||||
`CPR_REDIS_PASSWORD`,避免启动配置被部署环境覆盖
|
||||
|
||||
+8
-3
@@ -159,8 +159,13 @@ openssl rand -hex 24
|
||||
推荐使用模板中的 7 天不活动期限和 30 天最长登录期限。修改配置需重启,不能延长已建立会话的最长有效期。
|
||||
浏览器会话与续期规则见[认证 API](../docs/api.md#统一登录与会话),独立于 `/v1/*` 鉴权和限额
|
||||
|
||||
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区
|
||||
引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL
|
||||
PostgreSQL 与 Redis 密码统一接受 16–128 字节,不能全为空白或包含控制字符;仍须满足
|
||||
各自服务端的密码策略。托管服务可使用服务商允许的高强度密码,默认安装继续生成 48 位随机十六进制密码
|
||||
|
||||
Compose 通过 `config.yaml` 的凭据桥接区引用同一密码;三个值都不需要额外导出为环境变量,
|
||||
数据库和 Redis 密码不能嵌入连接 URL。通过环境变量提供密码时分别使用
|
||||
`CPR_DATABASE_PASSWORD` 与 `CPR_REDIS_PASSWORD`,原始密码由连接 URL 构造器编码,不要预先做 URL 编码。Compose 凭据桥接建议沿用上面的
|
||||
随机十六进制密码,避免 `$` 的变量插值语义
|
||||
|
||||
Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和日志目录设为 `0770`,
|
||||
配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。
|
||||
@@ -174,7 +179,7 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
|
||||
store:
|
||||
redis:
|
||||
url: 'redis://u1@redis-host:6379/0'
|
||||
password: &redis_password '<u1 的 48 位十六进制密码>'
|
||||
password: &redis_password '<u1 的密码>'
|
||||
```
|
||||
|
||||
将这两项合并到已有配置,保留其他配置和密码锚点;Redis 服务端需事先创建并授权该用户。
|
||||
|
||||
@@ -44,11 +44,13 @@ store:
|
||||
database:
|
||||
# Docker 内由 CPR_DATABASE_URL 覆盖;密码不要写进 URL。
|
||||
url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy'
|
||||
# 16–128 字节,不能全为空白或含控制字符,并满足服务端密码策略
|
||||
password: &postgres_password ''
|
||||
redis:
|
||||
# Docker 内由 CPR_REDIS_URL 覆盖;密码不要写进 URL。
|
||||
# ACL 用户写在 URL 中,例如 redis://u1@redis-host:6379/0;配置步骤见 deploy/README.md。
|
||||
url: 'redis://127.0.0.1:6379/'
|
||||
# 与 PostgreSQL 共用密码格式要求,并满足 Redis 服务端密码策略
|
||||
password: &redis_password ''
|
||||
# 可选:PostgreSQL 连接池预算;下列值与当前推荐默认一致。
|
||||
# pool:
|
||||
|
||||
+13
-11
@@ -754,7 +754,10 @@ Codex 的 `model_catalog_json` 配置。账号设置保存不等待上游模型
|
||||
`quota.credits` 返回上游点数余额,未提供点数信息时为 `null`。对象包含 `hasCredits`、`unlimited` 和
|
||||
`balance`,余额为保留上游精度的十进制字符串,未提供余额时为 `null`,明确的零余额为 `"0"`。
|
||||
OpenAI 点数随现有额度刷新和正常请求的额度信息同步,不与主动重置卡库存或百分比限额合并,
|
||||
不参与账号可用性判断
|
||||
不根据 `balance` 单独推导可用性。主动查询明确 `hasCredits=true` 或 `unlimited=true`,且无阻止
|
||||
点数支出的上限或工作区限制时,即使套餐窗口耗尽也可使用点数,并解除已有额度耗尽状态。
|
||||
真实额度拒绝后的后台回读同样支持此恢复路径,但不会自动重发本次失败请求;点数不能清除
|
||||
凭据错误、停用或冷却状态。上游点数信息也可能延迟,后续真实拒绝仍会记录额度耗尽
|
||||
|
||||
账号列表和详情返回 `notes`(无备注时为 `null`)。编辑时省略或 `null` 保留原备注;字符串最多 500 个 Unicode
|
||||
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
|
||||
@@ -1118,11 +1121,14 @@ OAuth start 使用:
|
||||
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow
|
||||
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
|
||||
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度
|
||||
- OpenAI 已耗尽账号每 30 分钟主动复核一次,也会在最早未恢复窗口的 `resetAt + 2 分钟` 到期后
|
||||
提前复核;周期复核不等待旧重置时间,因此也能发现官方提前重置。正常账号有非零用量或触顶窗口时,
|
||||
在该窗口的 `resetAt + 2 分钟` 后主动复核。后台每 30 秒检查触发条件;同一重置边界复核后仍未更新时
|
||||
回到 30 分钟重试,避免旧 reset 持续触发请求。各窗口独立确认恢复,时间到期本身不会直接解除账号
|
||||
耗尽或将展示用量归零
|
||||
- OpenAI 已耗尽账号首次可复核,在 `resetAt` 尚未到达或未知时按 30 分钟间隔兜底,也可发现
|
||||
官方提前重置。到达最早未恢复窗口的 `resetAt` 后,先等待 10 秒传播余量,再在下一轮后台检查
|
||||
复核;reset 后前 5 分钟最小查询间隔为 30 秒,之后仍耗尽则改为 5 分钟。正常账号有非零用量或触顶窗口时,仍在
|
||||
`resetAt + 2 分钟` 后复核,未更新则按 30 分钟间隔重试。后台每 30 秒检查条件,实际恢复时间还受
|
||||
排队、查询耗时和上游传播影响;查询失败也遵守间隔。无可用点数时,到期前仍按原窗口证据恢复;
|
||||
本次耗尽之后的已记录窗口 reset 全部到期后,新查询明确 `allowed=true` 可解除限制,即使用量仍为
|
||||
100%。未知 reset 或再次拒绝后残留的旧 reset 仍走原规则,不复用旧边界解封;时间到期本身不会
|
||||
解除限制,也不会将展示用量归零
|
||||
- 账号 `enabled` 控制是否参与请求调度,不控制 OAuth 凭据续期。OpenAI 与 xAI 的停用账号仍按各自刷新
|
||||
策略维护 Token;刷新结果更新凭据状态,但不会启用调度。无 refresh token 或凭据已进入失效、无效、
|
||||
封禁状态的账号不参加后台自动续期
|
||||
@@ -2055,7 +2061,7 @@ OpenAI 仅使用同一条 `response.completed` 中的 `created_at` 与 `complete
|
||||
|
||||
`latencyDetails` 还可包含以下上游性能指标,单位为毫秒,保留上游返回的小数。
|
||||
OpenAI 从当前响应的 `responsesapi.websocket_timing.timing_metrics` 读取,
|
||||
WebSocket 握手默认请求专项计时,客户端显式提供的开关保留原值。
|
||||
WebSocket 握手不主动添加专项计时请求头,客户端显式提供的开关保留原值。
|
||||
各字段独立可选;响应开始前、终态后的计时和明确属于其他响应的事件不写入当前请求,重试时清空
|
||||
|
||||
`critical_path` 仅采集 `scope=response`、`coverage=complete` 且边界为
|
||||
@@ -2067,10 +2073,6 @@ WebSocket 握手默认请求专项计时,客户端显式提供的开关保留
|
||||
| --- | --- |
|
||||
| `upstreamApiOverheadMs` | API 排除引擎与客户端工具时间后的耗时 |
|
||||
| `upstreamEngineMs` | 响应级引擎耗时,取完整 critical path 的墙钟时间或直接返回的 Engine Service 时间 |
|
||||
| `upstreamEngineIapiTtftMs` | Engine IAPI TTFT 总计 |
|
||||
| `upstreamEngineServiceTtftMs` | Engine Service TTFT 总计 |
|
||||
| `upstreamEngineIapiTbtMs` | 跨引擎调用的 IAPI Token 间隔 |
|
||||
| `upstreamEngineServiceTbtMs` | 跨引擎调用的 Service Token 间隔 |
|
||||
|
||||
以上指标按上游内部口径展示,不相加重建总耗时,不替换请求级 `firstTokenLatencyMs`。
|
||||
它们仅补充性能诊断,不改变列表与聚合统计的速率分母
|
||||
|
||||
@@ -890,6 +890,12 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
|
||||
OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护;
|
||||
空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料
|
||||
|
||||
OpenAI Provider 的额度快照统一解释点数可用性,主动查询、耗尽恢复与失败后回读复用同一规则。
|
||||
可用点数独立于套餐窗口;通过点数恢复时原子保存新快照、访问事实并清理旧窗口恢复进度。
|
||||
无可用点数的失败回读保留已确认的拒绝;常规刷新在到期前仍按窗口证据恢复,本次耗尽之后的
|
||||
已记录窗口 reset 全部到期后可依据新的明确放行观察恢复。查询沿用 credential revision 与
|
||||
观察时间隔离,不允许旧查询撤销较新的失败;恢复条件和后台复核节奏见 [账号 API](api.md#账号能力导入与-oauth)
|
||||
|
||||
OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号用例通过现有 Provider 管理端口并发读取
|
||||
个人资料统计与订阅,汇聚为一次只读响应;Provider 继续拥有各自的认证、出站代理与上游协议处理。
|
||||
订阅不由 quota、导入或后台任务触发,不持久化。汇聚结果返回前核对账号身份与 credential revision,
|
||||
|
||||
@@ -59,10 +59,6 @@ export interface UsageLatencyDetails {
|
||||
upstreamResponseMs?: number
|
||||
upstreamApiOverheadMs?: number
|
||||
upstreamEngineMs?: number
|
||||
upstreamEngineIapiTtftMs?: number
|
||||
upstreamEngineServiceTtftMs?: number
|
||||
upstreamEngineIapiTbtMs?: number
|
||||
upstreamEngineServiceTbtMs?: number
|
||||
admissionDecisionMs?: number
|
||||
accountSelectionWaitMs?: number
|
||||
capacityUsedSlots?: number
|
||||
|
||||
@@ -197,10 +197,6 @@ export function usageLatencyDetails(record: UsageLatencyRecord) {
|
||||
const performanceItems = [
|
||||
{ label: 'API 开销', value: durationValue(latencyDetails?.upstreamApiOverheadMs) },
|
||||
{ label: '引擎耗时', value: durationValue(latencyDetails?.upstreamEngineMs) },
|
||||
{ label: 'TTFT · IAPI', value: durationValue(latencyDetails?.upstreamEngineIapiTtftMs) },
|
||||
{ label: 'TTFT · Service', value: durationValue(latencyDetails?.upstreamEngineServiceTtftMs) },
|
||||
{ label: 'Token 间隔 · IAPI', value: durationValue(latencyDetails?.upstreamEngineIapiTbtMs) },
|
||||
{ label: 'Token 间隔 · Service', value: durationValue(latencyDetails?.upstreamEngineServiceTbtMs) },
|
||||
]
|
||||
for (const item of performanceItems) {
|
||||
if (item.value !== null)
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
# v3.29.3
|
||||
|
||||
## 修复与调整
|
||||
|
||||
- 修复 OpenAI 账号套餐额度耗尽后仍有可用点数却无法参与调度的问题;上游明确点数可用且无支出或工作区限制时,可保持或恢复账号可用,失败后的后台回读也支持恢复,本次失败请求不会自动重发
|
||||
- 改进 OpenAI 额度重置后的恢复:本次耗尽后记录的窗口重置时间全部到期,且新查询明确允许使用时,可在展示用量仍为 100% 时恢复;耗尽账号在重置后等待 10 秒传播余量再由后台复核,前 5 分钟最小查询间隔为 30 秒,之后为 5 分钟,实际恢复时间仍受后台扫描与上游响应影响
|
||||
- PostgreSQL 与 Redis 密码统一支持 16–128 字节,拒绝全空白和控制字符,兼容托管服务的密码策略;修复密码中的 `%41`、`%25` 等字面量百分号序列被错误解码、导致认证失败的问题
|
||||
- OpenAI Responses WebSocket 转发保留 `response.create` 的原始字段顺序,并对齐 OAuth 授权请求的参数顺序
|
||||
- WebSocket 握手不再默认请求专项计时,保留客户端显式设置;使用统计保留 API 开销与引擎耗时,移除四个细分引擎 TTFT 和 Token 间隔指标,请求速率计算口径不变
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 使用统计、密钥用量 API 与插件观测合同不再提供四个细分耗时字段:`upstreamEngineIapiTtftMs`、`upstreamEngineServiceTtftMs`、`upstreamEngineIapiTbtMs`、`upstreamEngineServiceTbtMs`;插件 SDK 中对应的蛇形命名字段同步移除,依赖这些字段的自定义调用方和插件需适配
|
||||
- 数据库与 Redis 的现有 48 位十六进制密码继续有效;配置与环境变量应填写原始密码,不要预先进行 URL 编码,密码仍须符合服务端策略
|
||||
|
||||
# v3.29.2
|
||||
|
||||
## 修复与调整
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.29.2"
|
||||
version: "3.29.3"
|
||||
|
||||
Reference in New Issue
Block a user