Compare commits

..
5 Commits
79 changed files with 5478 additions and 1172 deletions
+5 -6
View File
@@ -15,11 +15,6 @@ APP_PORT=8084
# APP_IMAGE=ghcr.io/fawney19/aether:beta
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
# Compose 应用容器的非 root 数字身份。
# install.sh 会自动写入安装用户的 UID/GID。
AETHER_CONTAINER_UID=65532
AETHER_CONTAINER_GID=65532
# API Key 前缀(默认 sk)
API_KEY_PREFIX=sk
@@ -31,7 +26,11 @@ RUST_LOG=aether_gateway=info
# 示例: http://localhost:5173,https://app.example.com
# CORS_ORIGINS=http://localhost:5173
# CORS_ALLOW_CREDENTIALS=true
# 如果前后端跨站并依赖登录刷新 Cookie,还要配合:
# 登录刷新 Cookie 对同源浏览器请求和可信反代自动适配 HTTP/HTTPS。
# HTTP 自动使用兼容的 SameSite=Lax(显式 Strict 保留);HTTPS 保留原有 SameSite 配置。
# 无法确认访问协议时保留安全默认值;HTTPS 反代请正确传递 X-Forwarded-Proto。
# AUTH_REFRESH_COOKIE_SECURE 可显式覆盖自动判断,公网部署仍建议使用 HTTPS。
# 如果前后端跨站并依赖登录刷新 Cookie,必须使用 HTTPS,并配合:
# AUTH_REFRESH_COOKIE_SAMESITE=None
# AUTH_REFRESH_COOKIE_SECURE=true
+1 -1
View File
@@ -44,5 +44,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER 65532:65532
USER 0:0
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
+1
View File
@@ -157,4 +157,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
USER 0:0
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
+1
View File
@@ -156,4 +156,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER 0:0
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
+1 -73
View File
@@ -50,82 +50,10 @@ chmod 600 .env
./generate_keys.sh
# 编辑 .env 设置 ADMIN_PASSWORD
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
# Postgres + Redis (推荐)
# 3. Docker 部署 / 更新(PostgreSQL + Redis)
docker compose pull && docker compose up -d
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
```
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec` 的 `/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
### 一键更新
Docker Compose 部署后,可在部署目录直接执行:
```bash
./update.sh
```
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
```bash
./update.sh --mode single-node
```
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY` 和 `ENCRYPTION_KEY`,不要重新生成整个 `.env`。
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag:
```bash
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
--repo fawney19/Aether \
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
--source-ref "refs/tags/${TAG}" \
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
```
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
如果是本地源码构建镜像的部署,继续使用:
```bash
./deploy.sh
```
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
```bash
docker compose -f docker-compose.release-local.yml up -d --build
```
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
```bash
docker compose -f docker-compose.release-local.yml down -v
```
可选变量:
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
### 一键安装(PostgreSQL + Redis)
```bash
@@ -1593,6 +1593,19 @@ impl GatewayDataState {
}
}
pub(crate) async fn read_request_usage_body_payload(
&self,
body_ref: &str,
) -> Result<
Option<aether_data_contracts::repository::usage::StoredUsageBodyPayload>,
DataLayerError,
> {
match &self.usage_reader {
Some(repository) => repository.read_body_payload(body_ref).await,
None => Ok(None),
}
}
pub(crate) async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
+29 -43
View File
@@ -756,13 +756,8 @@ fn runtime_miss_client_error_body(api_format: Option<&str>, message: &str) -> Va
}
fn runtime_miss_original_headers_json(headers: &HeaderMap) -> Value {
let mut headers = crate::headers::collect_control_headers(headers);
for (name, value) in headers.iter_mut() {
if runtime_miss_sensitive_header(name) {
*value = runtime_miss_mask_header_value(value);
}
}
serde_json::to_value(headers).unwrap_or_else(|_| json!({}))
serde_json::to_value(crate::headers::collect_control_headers(headers))
.unwrap_or_else(|_| json!({}))
}
fn runtime_miss_original_request_body_json(
@@ -784,40 +779,6 @@ fn runtime_miss_original_request_body_json(
})
}
fn runtime_miss_sensitive_header(name: &str) -> bool {
const SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"cookie",
"proxy-authorization",
];
SENSITIVE_HEADERS
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
}
fn runtime_miss_mask_header_value(value: &str) -> String {
let value = value.trim();
let char_count = value.chars().count();
if char_count <= 8 {
return "****".to_string();
}
let prefix: String = value.chars().take(4).collect();
let suffix: String = value
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}****{suffix}")
}
async fn load_runtime_miss_candidate_contexts(
state: &AppState,
request_id: &str,
@@ -1233,8 +1194,9 @@ mod tests {
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
insert_runtime_miss_candidate_usage_metadata,
request_candidate_represents_provider_execution, runtime_miss_client_error_body,
select_last_runtime_miss_executed_candidate, select_last_runtime_miss_routing_candidate,
LocalExecutionRuntimeMissContext, RuntimeMissCandidateContext,
runtime_miss_original_headers_json, select_last_runtime_miss_executed_candidate,
select_last_runtime_miss_routing_candidate, LocalExecutionRuntimeMissContext,
RuntimeMissCandidateContext,
};
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
use crate::state::LocalExecutionRuntimeMissDiagnostic;
@@ -1266,6 +1228,30 @@ mod tests {
);
}
#[test]
fn runtime_miss_usage_preserves_original_request_headers() {
let expected = json!({
"authorization": "Bearer original-client-token",
"x-api-key": "short",
"api-key": "original-api-key",
"x-goog-api-key": "original-google-key",
"cookie": "session=original-client",
"proxy-authorization": "Basic original-proxy-token",
"originator": "codex-cli",
"session-id": "original-session",
"x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}"
});
let mut headers = http::HeaderMap::new();
for (name, value) in expected.as_object().unwrap() {
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
http::HeaderValue::from_str(value.as_str().unwrap()).unwrap(),
);
}
assert_eq!(runtime_miss_original_headers_json(&headers), expected);
}
#[test]
fn runtime_miss_usage_body_matches_claude_client_envelope() {
let claude = runtime_miss_client_error_body(Some("claude:messages"), "busy");
@@ -17,7 +17,10 @@ use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField};
use aether_data_contracts::repository::usage::{
canonical_usage_body_ref_for, StoredRequestUsageAudit, StoredUsageBodyPayload,
UsageBodyCaptureState, UsageBodyField, MAX_DECOMPRESSED_USAGE_JSON_BYTES,
};
use axum::{
body::Body,
http,
@@ -28,9 +31,59 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use tokio::try_join;
#[derive(Default)]
struct AdminUsageDetailBodyValue {
value: Option<Value>,
load_failed: bool,
error_code: Option<&'static str>,
}
impl AdminUsageDetailBodyValue {
fn resolved(
item: &StoredRequestUsageAudit,
field: UsageBodyField,
value: Option<Value>,
) -> Self {
let missing = value.is_none()
&& item
.body_capture_result(field, item.body_value(field))
.available;
Self {
value,
error_code: missing.then_some("missing"),
}
}
}
fn admin_usage_body_load_error_code(error: &GatewayError) -> &'static str {
if let GatewayError::Internal(message) = error {
if message.contains("decompressed usage json exceeds ")
|| message.contains("encoded usage json exceeds ")
{
return "too_large";
}
if message.contains("failed to decompress usage json:")
|| message.contains("failed to parse decompressed usage json:")
{
return "decode_failed";
}
}
"storage_unavailable"
}
async fn resolve_admin_usage_detail_field(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
selected_field: Option<UsageBodyField>,
) -> AdminUsageDetailBodyValue {
if selected_field.is_some_and(|selected| selected != field) {
return AdminUsageDetailBodyValue::default();
}
if field == UsageBodyField::RequestBody {
resolve_admin_usage_detail_request_body(state, item).await
} else {
resolve_admin_usage_detail_body_value(state, item, field).await
}
}
async fn resolve_admin_usage_detail_request_body(
@@ -38,10 +91,7 @@ async fn resolve_admin_usage_detail_request_body(
item: &StoredRequestUsageAudit,
) -> AdminUsageDetailBodyValue {
match admin_usage_resolve_request_capture_body_for_item(state, item, None).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Ok(body) => AdminUsageDetailBodyValue::resolved(item, UsageBodyField::RequestBody, body),
Err(err) => {
tracing::warn!(
error = ?err,
@@ -52,7 +102,9 @@ async fn resolve_admin_usage_detail_request_body(
);
let value = admin_usage_resolve_request_capture_body(item, None);
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
error_code: value
.is_none()
.then(|| admin_usage_body_load_error_code(&err)),
value,
}
}
@@ -66,10 +118,7 @@ async fn resolve_admin_usage_detail_body_value(
) -> AdminUsageDetailBodyValue {
let inline_body = item.body_value(field);
match admin_usage_resolve_body_value(state, item, inline_body, field).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Ok(body) => AdminUsageDetailBodyValue::resolved(item, field, body),
Err(err) => {
tracing::warn!(
error = ?err,
@@ -80,13 +129,139 @@ async fn resolve_admin_usage_detail_body_value(
);
let value = inline_body.cloned();
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
error_code: value
.is_none()
.then(|| admin_usage_body_load_error_code(&err)),
value,
}
}
}
}
async fn read_admin_usage_raw_body(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
) -> Result<Option<StoredUsageBodyPayload>, GatewayError> {
if matches!(
item.body_state(field),
Some(
UsageBodyCaptureState::Disabled
| UsageBodyCaptureState::Unavailable
| UsageBodyCaptureState::None
)
) {
return Ok(None);
}
let inline_body = item.body_value(field);
let prefer_inline = matches!(
item.body_state(field),
Some(UsageBodyCaptureState::Inline | UsageBodyCaptureState::Truncated)
) && inline_body.is_some();
if !prefer_inline {
if let Some(body_ref) = item
.body_ref(field)
.and_then(|reference| canonical_usage_body_ref_for(reference, &item.request_id, field))
{
if let Some(payload) = state.read_request_usage_body_payload(&body_ref).await? {
return Ok(Some(payload));
}
}
}
let fallback = inline_body.cloned().or_else(|| {
(field == UsageBodyField::RequestBody)
.then(|| admin_usage_resolve_request_capture_body(item, None))
.flatten()
});
fallback
.map(|value| {
serde_json::to_vec(&value)
.map(StoredUsageBodyPayload::Json)
.map_err(|error| GatewayError::Internal(error.to_string()))
})
.transpose()
}
async fn build_admin_usage_raw_body_response(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
) -> Response<Body> {
let result = read_admin_usage_raw_body(state, item, field).await;
let mut response = match result {
Ok(Some(payload)) => admin_usage_raw_payload_response(payload),
Ok(None) => admin_usage_raw_body_error(http::StatusCode::NOT_FOUND, "missing"),
Err(error) => {
tracing::warn!(error = ?error, usage_id = %item.id, field = field.as_storage_field(), "failed to read admin usage raw body");
let code = admin_usage_body_load_error_code(&error);
admin_usage_raw_body_error(
if code == "too_large" {
http::StatusCode::PAYLOAD_TOO_LARGE
} else {
http::StatusCode::SERVICE_UNAVAILABLE
},
code,
)
}
};
let headers = response.headers_mut();
headers.insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-store, no-transform"),
);
headers.insert(
"x-content-type-options",
http::HeaderValue::from_static("nosniff"),
);
headers.insert(
"x-aether-body-field",
http::HeaderValue::from_static(field.as_storage_field()),
);
if let Ok(value) = http::HeaderValue::from_str(&item.id) {
headers.insert("x-aether-usage-id", value);
}
attach_admin_audit_response(
response,
"admin_usage_detail_viewed",
"view_usage_detail",
"usage_record",
&item.id,
)
}
fn admin_usage_raw_payload_response(payload: StoredUsageBodyPayload) -> Response<Body> {
let (encoding, bytes, limit) = match payload {
StoredUsageBodyPayload::Gzip(bytes) => (
"gzip",
bytes,
MAX_DECOMPRESSED_USAGE_JSON_BYTES + 1024 * 1024,
),
StoredUsageBodyPayload::Json(bytes) => ("json", bytes, MAX_DECOMPRESSED_USAGE_JSON_BYTES),
};
if bytes.len() > limit {
admin_usage_raw_body_error(http::StatusCode::PAYLOAD_TOO_LARGE, "too_large")
} else {
(
[
("content-type", "application/octet-stream"),
("content-encoding", "identity"),
("x-aether-body-encoding", encoding),
],
bytes,
)
.into_response()
}
}
fn admin_usage_raw_body_error(status: http::StatusCode, code: &'static str) -> Response<Body> {
(
status,
[("x-aether-body-error", code)],
Json(json!({ "body_load_error_code": code })),
)
.into_response()
}
pub(super) async fn maybe_build_local_admin_usage_detail_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -212,6 +387,25 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
"include_bodies",
true,
);
let body_field =
match request_context
.request_query_string
.as_deref()
.and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "body_field")
.map(|(_, value)| value.into_owned())
}) {
Some(value) => {
match UsageBodyField::from_storage_field(value.trim()) {
Some(field) if include_bodies => Some(field),
_ => return Ok(Some(admin_usage_bad_request_response(
"body_field 必须是有效的正文字段,且 include_bodies 必须为 true",
))),
}
}
None => None,
};
let Some(item) = state.find_request_usage_by_id(&usage_id).await? else {
return Ok(Some(
@@ -223,6 +417,27 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
));
};
let body_format = request_context
.request_query_string
.as_deref()
.and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "body_format")
.map(|(_, value)| value.into_owned())
});
if let Some(format) = body_format {
if format != "raw" || body_field.is_none() {
return Ok(Some(admin_usage_bad_request_response(
"body_format=raw 必须指定 body_field",
)));
}
if let Some(field) = body_field {
return Ok(Some(
build_admin_usage_raw_body_response(state, &item, field).await,
));
}
}
let user_ids = item.user_id.clone().into_iter().collect::<Vec<_>>();
let (users_by_id, provider_key_names, api_key_names): (
BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
@@ -250,23 +465,32 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
}
}
let mut body_load_errors = serde_json::Map::new();
let request_body = if include_bodies {
let mut body_load_error_codes = serde_json::Map::new();
let mut request_body = if include_bodies {
let (request_body, provider_request_body, response_body, client_response_body) = tokio::join!(
resolve_admin_usage_detail_request_body(state, &item),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::RequestBody,
body_field
),
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ProviderRequestBody,
body_field,
),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ResponseBody,
body_field,
),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ClientResponseBody,
body_field,
),
);
for (field, resolved) in [
@@ -275,20 +499,25 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
(UsageBodyField::ResponseBody, &response_body),
(UsageBodyField::ClientResponseBody, &client_response_body),
] {
if resolved.load_failed {
if let Some(error_code) = resolved.error_code {
body_load_errors.insert(field.as_storage_field().to_string(), json!(true));
body_load_error_codes
.insert(field.as_storage_field().to_string(), json!(error_code));
}
}
detail_item.provider_request_body = provider_request_body.value;
detail_item.response_body = response_body.value;
detail_item.client_response_body = client_response_body.value;
if body_field.is_none_or(|field| field == UsageBodyField::ProviderRequestBody) {
detail_item.provider_request_body = provider_request_body.value;
}
if body_field.is_none_or(|field| field == UsageBodyField::ResponseBody) {
detail_item.response_body = response_body.value;
}
if body_field.is_none_or(|field| field == UsageBodyField::ClientResponseBody) {
detail_item.client_response_body = client_response_body.value;
}
request_body.value
} else {
None
};
if include_bodies {
// request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。
}
let default_headers = admin_usage_curl_headers();
let mut payload = build_admin_usage_detail_payload(
&detail_item,
@@ -297,15 +526,33 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
state.has_auth_user_data_reader(),
state.has_auth_api_key_data_reader(),
provider_key_name.as_deref(),
include_bodies,
request_body,
include_bodies && body_field.is_none(),
if body_field.is_none() {
request_body.take()
} else {
None
},
&default_headers,
);
if let Some(field) = body_field {
payload[field.as_storage_field()] = match field {
UsageBodyField::RequestBody => request_body,
UsageBodyField::ProviderRequestBody => detail_item.provider_request_body.take(),
UsageBodyField::ResponseBody => detail_item.response_body.take(),
UsageBodyField::ClientResponseBody => detail_item.client_response_body.take(),
}
.unwrap_or(Value::Null);
}
payload["body_load_errors"] = if include_bodies && !body_load_errors.is_empty() {
Value::Object(body_load_errors)
} else {
Value::Null
};
payload["body_load_error_codes"] = if body_load_error_codes.is_empty() {
Value::Null
} else {
Value::Object(body_load_error_codes)
};
return Ok(Some(attach_admin_audit_response(
Json(payload).into_response(),
@@ -320,3 +567,61 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
Ok(None)
}
#[cfg(test)]
mod tests {
use super::admin_usage_body_load_error_code;
use crate::GatewayError;
#[tokio::test]
async fn admin_usage_raw_body_does_not_decode_or_reencode_stored_bytes() {
use super::{admin_usage_raw_payload_response, StoredUsageBodyPayload};
for (payload, encoding, expected) in [
(
StoredUsageBodyPayload::Gzip(vec![31, 139, 8, 0, 1]),
"gzip",
vec![31, 139, 8, 0, 1],
),
(
StoredUsageBodyPayload::Json(b"{ \"untouched\" : true }".to_vec()),
"json",
b"{ \"untouched\" : true }".to_vec(),
),
] {
let response = admin_usage_raw_payload_response(payload);
assert_eq!(response.headers()["content-encoding"], "identity");
assert_eq!(response.headers()["x-aether-body-encoding"], encoding);
let bytes = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert_eq!(bytes.as_ref(), expected.as_slice());
}
}
#[test]
fn body_load_errors_expose_safe_codes_instead_of_internal_messages() {
for (message, expected) in [
(
"unexpected database value: decompressed usage json exceeds 67108864 bytes",
"too_large",
),
(
"failed to decompress usage json: invalid gzip header",
"decode_failed",
),
(
"failed to parse decompressed usage json: invalid JSON",
"decode_failed",
),
(
"postgres error: private connection details",
"storage_unavailable",
),
] {
assert_eq!(
admin_usage_body_load_error_code(&GatewayError::Internal(message.to_string())),
expected
);
}
}
}
@@ -1463,7 +1463,7 @@ mod tests {
&auth_config,
Some(0),
),
"antigravity_[email protected]"
"[email protected]"
);
}
@@ -1,3 +1,4 @@
use super::super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::super::kiro::{
admin_provider_oauth_kiro_refresh_base_url_override, fetch_admin_provider_oauth_kiro_email,
refresh_admin_provider_oauth_kiro_auth_config,
@@ -79,7 +80,7 @@ fn kiro_social_key_name(
.collect::<String>()
})
.unwrap_or_else(|| "unknown".to_string());
format!("kiro_{fallback} ({provider})")
format!("账号_{fallback} ({provider})")
}
fn kiro_social_poll_error_response(error: impl Into<String>) -> Response<Body> {
@@ -1004,10 +1005,11 @@ async fn handle_admin_provider_oauth_windsurf_browser_device_poll(
}
}
} else {
let key_name = email
.as_deref()
.map(|email| format!("windsurf_{email}"))
.unwrap_or_else(|| format!("windsurf_{}", current_unix_secs()));
let key_name = admin_provider_oauth_key_name_from_auth_config(
&provider.provider_type,
&auth_config,
None,
);
match state
.create_provider_oauth_catalog_key(
&provider.id,
@@ -1356,6 +1358,32 @@ mod tests {
use crate::control::GatewayAdminPrincipalContext;
use aether_data::repository::provider_oauth::StoredAdminProviderOAuthDeviceSession;
#[test]
fn kiro_social_key_name_preserves_email_and_auth_method() {
assert_eq!(
super::kiro_social_key_name(
Some(" [email protected] "),
Some("Github"),
Some("refresh-token-1"),
),
"[email protected] (Github)"
);
}
#[test]
fn kiro_social_key_name_without_email_uses_generic_account_prefix() {
for email in [None, Some(""), Some(" ")] {
assert_eq!(
super::kiro_social_key_name(email, Some("Google"), Some("refresh-token-1")),
"账号_154f43 (Google)"
);
assert_eq!(
super::kiro_social_key_name(email, None, None),
"账号_unknown (social)"
);
}
}
fn device_session() -> StoredAdminProviderOAuthDeviceSession {
StoredAdminProviderOAuthDeviceSession {
session_id: "device-session-1".to_string(),
@@ -52,13 +52,12 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config(
auth_config: &Map<String, Value>,
batch_index: Option<usize>,
) -> String {
let provider_type = provider_type.trim();
if let Some(email) = trimmed_auth_config_string(auth_config, "email") {
return format!("{provider_type}_{email}");
return email;
}
if provider_type.eq_ignore_ascii_case("grok") {
if provider_type.trim().eq_ignore_ascii_case("grok") {
if let Some(user_id) = trimmed_auth_config_string(auth_config, "user_id") {
return format!("grok_{user_id}");
return user_id;
}
}
@@ -68,7 +67,7 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config(
.map(|duration| duration.as_secs())
.unwrap_or(0);
match batch_index {
Some(index) => format!("{provider_type}_{timestamp}_{index}"),
Some(index) => format!("账号_{timestamp}_{index}"),
None => format!("账号_{timestamp}"),
}
}
@@ -87,6 +86,106 @@ mod tests {
use super::*;
use serde_json::{json, Map};
const PROVIDER_TYPES: &[&str] = &[
"codex",
" Codex ",
"claude_code",
"chatgpt_web",
"gemini_cli",
"antigravity",
"grok",
" Grok ",
"kiro",
"windsurf",
];
#[test]
fn default_key_name_uses_email_without_provider_prefix() {
let mut auth_config = Map::new();
auth_config.insert("email".to_string(), json!(" [email protected] "));
for provider_type in PROVIDER_TYPES {
for batch_index in [None, Some(3)] {
assert_eq!(
admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
batch_index,
),
"[email protected]"
);
}
}
}
#[test]
fn antigravity_default_key_name_uses_email_without_provider_prefix() {
for email in [" [email protected] ", "[email protected]"] {
let mut auth_config = Map::new();
auth_config.insert("email".to_string(), json!(email));
for provider_type in ["antigravity", " Antigravity "] {
for batch_index in [None, Some(3)] {
assert_eq!(
admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
batch_index,
),
email.trim()
);
}
}
}
}
#[test]
fn default_key_name_preserves_email_with_provider_prefix() {
for provider_type in PROVIDER_TYPES {
let email = format!("{}[email protected]", provider_type.trim());
let mut auth_config = Map::new();
auth_config.insert("email".to_string(), json!(email));
for batch_index in [None, Some(3)] {
assert_eq!(
admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
batch_index,
),
email
);
}
}
}
#[test]
fn default_key_name_without_email_uses_generic_account_name() {
for email in [None, Some(""), Some(" ")] {
let mut auth_config = Map::new();
if let Some(email) = email {
auth_config.insert("email".to_string(), json!(email));
}
for provider_type in PROVIDER_TYPES {
for batch_index in [None, Some(3)] {
let name = admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
batch_index,
);
let suffix = name.strip_prefix("账号_").expect("generic account prefix");
let timestamp = if batch_index.is_some() {
suffix.strip_suffix("_3").expect("batch index suffix")
} else {
suffix
};
assert!(timestamp.parse::<u64>().is_ok());
}
}
}
}
#[test]
fn grok_default_key_name_uses_full_user_id() {
let mut auth_config = Map::new();
@@ -95,10 +194,18 @@ mod tests {
json!("1619039a-0191-4e0a-a490-8f4ad21262c9"),
);
assert_eq!(
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
"grok_1619039a-0191-4e0a-a490-8f4ad21262c9"
);
for provider_type in ["grok", " Grok "] {
for batch_index in [None, Some(3)] {
assert_eq!(
admin_provider_oauth_key_name_from_auth_config(
provider_type,
&auth_config,
batch_index,
),
"1619039a-0191-4e0a-a490-8f4ad21262c9"
);
}
}
}
#[test]
@@ -109,17 +216,22 @@ mod tests {
assert_eq!(
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
"grok_grok@example.com"
"[email protected]"
);
}
#[test]
fn batch_default_key_name_keeps_existing_timestamp_shape() {
fn batch_default_key_name_keeps_distinct_indexes_without_provider_prefix() {
let auth_config = Map::new();
let name = admin_provider_oauth_key_name_from_auth_config("codex", &auth_config, Some(3));
let name = admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, Some(3));
let other_name =
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, Some(4));
assert!(name.starts_with("codex_"));
assert!(name.starts_with("账号_"));
assert!(name.ends_with("_3"));
assert!(other_name.starts_with("账号_"));
assert!(other_name.ends_with("_4"));
assert_ne!(name, other_name);
}
#[test]
@@ -5,7 +5,6 @@ use super::shared::{
quota_key_auto_removed, quota_refresh_success_invalid_state,
resolve_provider_quota_execution_timeouts, ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::payloads::AdminImportProviderModelsRequest;
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_admin::provider::quota::{
@@ -24,63 +23,6 @@ use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
fn antigravity_discovered_model_ids(metadata_update: Option<&serde_json::Value>) -> Vec<String> {
metadata_update
.and_then(|value| value.pointer("/antigravity/quota_by_model"))
.and_then(serde_json::Value::as_object)
.into_iter()
.flat_map(|models| models.keys())
.map(String::as_str)
.filter(|model_id| aether_model_fetch::antigravity_model_id_is_routable(model_id))
.map(ToOwned::to_owned)
.collect()
}
async fn sync_antigravity_discovered_models(
state: &AdminAppState<'_>,
provider_id: &str,
metadata_update: Option<&serde_json::Value>,
) {
if !state.has_global_model_data_reader() || !state.has_global_model_data_writer() {
return;
}
let model_ids = antigravity_discovered_model_ids(metadata_update);
if model_ids.is_empty() {
return;
}
let result = state
.build_admin_import_provider_models_payload(
provider_id,
AdminImportProviderModelsRequest {
model_ids,
tiered_pricing: None,
price_per_request: None,
},
)
.await;
match result {
Ok(payload) => {
let errors = payload
.get("errors")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if errors > 0 {
warn!(
provider_id,
errors, "Antigravity discovered-model catalog sync completed with item errors"
);
}
}
Err(error) => warn!(
provider_id,
error = %error,
"Antigravity discovered-model catalog sync failed"
),
}
}
async fn execute_antigravity_quota_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
@@ -380,10 +322,6 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally(
continue;
}
if status == "success" {
sync_antigravity_discovered_models(state, &provider.id, metadata_update.as_ref()).await;
}
if status == "success" {
success_count += 1;
} else {
@@ -234,6 +234,20 @@ impl<'a> AdminAppState<'a> {
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_request_usage_body_payload(
&self,
body_ref: &str,
) -> Result<
Option<aether_data_contracts::repository::usage::StoredUsageBodyPayload>,
GatewayError,
> {
self.app
.data
.read_request_usage_body_payload(body_ref)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))
}
pub(crate) async fn build_api_format_health_monitor_payload(
&self,
lookback_hours: u64,
@@ -211,9 +211,19 @@ fn apply_sensitive_route_cache_policy(
return;
}
let preserve_no_transform = headers
.get_all(http::header::CACHE_CONTROL)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|directive| directive.trim().eq_ignore_ascii_case("no-transform"));
headers.insert(
http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
HeaderValue::from_static(if preserve_no_transform {
"no-store, no-transform"
} else {
"no-store"
}),
);
headers.insert(http::header::PRAGMA, HeaderValue::from_static("no-cache"));
}
@@ -354,6 +364,29 @@ mod tests {
);
}
#[test]
fn raw_body_no_transform_survives_sensitive_cache_policy() {
let mut headers = HeaderMap::new();
headers.append(
http::header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=3600"),
);
headers.append(
http::header::CACHE_CONTROL,
HeaderValue::from_static(" No-Transform "),
);
apply_sensitive_route_cache_policy(
&mut headers,
"/api/admin/usage/usage-1?body_format=raw",
None,
);
assert_eq!(
headers[http::header::CACHE_CONTROL],
"no-store, no-transform"
);
assert_eq!(headers[http::header::PRAGMA], "no-cache");
}
#[test]
fn authenticated_user_data_responses_are_never_cacheable() {
let mut headers = HeaderMap::new();
@@ -30,6 +30,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
mod support_announcements;
#[path = "support/auth.rs"]
mod support_auth;
#[path = "support/auth_cookie_policy.rs"]
mod support_auth_cookie_policy;
#[path = "support/billing.rs"]
mod support_billing;
#[path = "support/ccswitch.rs"]
@@ -133,6 +135,31 @@ pub(crate) async fn maybe_build_local_public_support_response(
remote_addr: &std::net::SocketAddr,
client_ip: std::net::IpAddr,
request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
let response = build_local_public_support_response(
state,
request_context,
headers,
remote_addr,
client_ip,
request_body,
)
.await?;
Some(support_auth_cookie_policy::finalize_refresh_cookie(
response,
headers,
request_context.host_header.as_deref(),
remote_addr,
))
}
async fn build_local_public_support_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
remote_addr: &std::net::SocketAddr,
client_ip: std::net::IpAddr,
request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_class.as_deref() != Some("public_support") {
@@ -0,0 +1,424 @@
use super::support_auth::{auth_refresh_cookie_name, auth_refresh_cookie_secure};
use axum::body::Body;
use axum::http::{header, HeaderMap, HeaderValue, Response};
use std::net::SocketAddr;
use url::Url;
pub(super) fn finalize_refresh_cookie(
mut response: Response<Body>,
headers: &HeaderMap,
host_header: Option<&str>,
remote_addr: &SocketAddr,
) -> Response<Body> {
if !response.headers().contains_key(header::SET_COOKIE) {
return response;
}
let cookie_name = auth_refresh_cookie_name();
let explicit_secure = std::env::var("AUTH_REFRESH_COOKIE_SECURE").ok();
let public_base_url = std::env::var("AETHER_PUBLIC_BASE_URL")
.ok()
.or_else(|| std::env::var("PUBLIC_BASE_URL").ok());
let secure = refresh_cookie_secure_for_request(
headers,
host_header,
crate::headers::trusted_proxy_ip(remote_addr.ip()),
explicit_secure.as_deref(),
public_base_url.as_deref(),
auth_refresh_cookie_secure(),
);
let cookies = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|cookie| rewrite_refresh_cookie(cookie, &cookie_name, secure))
.collect::<Vec<_>>();
response.headers_mut().remove(header::SET_COOKIE);
for cookie in cookies {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
response
}
fn refresh_cookie_secure_for_request(
headers: &HeaderMap,
host_header: Option<&str>,
trusted_proxy: bool,
explicit_secure: Option<&str>,
public_base_url: Option<&str>,
fallback_secure: bool,
) -> bool {
if let Some(value) = explicit_secure {
return !value.trim().eq_ignore_ascii_case("false");
}
let origin = single_header(headers, header::ORIGIN.as_str()).and_then(parse_origin);
let public_url = public_base_url.and_then(parse_http_url);
let forwarded_proto = trusted_proxy.then(|| forwarded_proto(headers)).flatten();
if origin.as_ref().is_some_and(|url| url.scheme() == "https")
|| public_url
.as_ref()
.is_some_and(|url| url.scheme() == "https")
|| forwarded_proto == Some("https")
{
return true;
}
if trusted_proxy && headers.contains_key("x-forwarded-proto") {
return forwarded_proto != Some("http");
}
if public_url
.as_ref()
.is_some_and(|url| url.scheme() == "http")
{
return false;
}
if let (Some(origin), Some(host)) = (origin, host_header) {
let request_origin = parse_origin(&format!("{}://{host}", origin.scheme()));
if request_origin.is_some_and(|url| url.origin() == origin.origin()) {
return false;
}
}
fallback_secure
}
fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
let mut values = headers.get_all(name).iter();
let value = values.next()?.to_str().ok()?.trim();
(values.next().is_none() && !value.is_empty()).then_some(value)
}
fn parse_origin(value: &str) -> Option<Url> {
let url = parse_http_url(value)?;
(url.path() == "/").then_some(url)
}
fn parse_http_url(value: &str) -> Option<Url> {
let url = Url::parse(value.trim()).ok()?;
(matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none())
.then_some(url)
}
fn forwarded_proto(headers: &HeaderMap) -> Option<&'static str> {
let value = headers
.get_all("x-forwarded-proto")
.iter()
.next_back()?
.to_str()
.ok()?
.rsplit(',')
.next()?
.trim();
if value.eq_ignore_ascii_case("https") {
Some("https")
} else if value.eq_ignore_ascii_case("http") {
Some("http")
} else {
None
}
}
fn rewrite_refresh_cookie(cookie: &HeaderValue, cookie_name: &str, secure: bool) -> HeaderValue {
let Ok(value) = cookie.to_str() else {
return cookie.clone();
};
let mut attributes = value.split(';').map(str::trim);
let Some(pair) = attributes.next() else {
return cookie.clone();
};
if pair.split_once('=').map(|(name, _)| name) != Some(cookie_name) {
return cookie.clone();
}
let secure =
secure || cookie_name.starts_with("__Secure-") || cookie_name.starts_with("__Host-");
let mut parts = vec![pair.to_string()];
for attribute in attributes {
if attribute.eq_ignore_ascii_case("Secure") {
continue;
}
if !secure
&& attribute.split_once('=').is_some_and(|(name, value)| {
name.trim().eq_ignore_ascii_case("SameSite")
&& value.trim().eq_ignore_ascii_case("None")
})
{
parts.push("SameSite=Lax".to_string());
} else {
parts.push(attribute.to_string());
}
}
if secure {
parts.push("Secure".to_string());
}
let Ok(mut rewritten) = HeaderValue::from_str(&parts.join("; ")) else {
return cookie.clone();
};
rewritten.set_sensitive(cookie.is_sensitive());
rewritten
}
#[cfg(test)]
mod tests {
use super::{refresh_cookie_secure_for_request, rewrite_refresh_cookie};
use axum::http::{header, HeaderMap, HeaderValue};
fn headers(origin: Option<&str>, forwarded_proto: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
}
if let Some(proto) = forwarded_proto {
headers.insert("x-forwarded-proto", HeaderValue::from_str(proto).unwrap());
}
headers
}
#[test]
fn refresh_cookie_auto_detects_same_origin_http_and_https() {
for (origin, host, secure) in [
("http://aether.test:8084", "aether.test:8084", false),
("http://aether.test", "aether.test:80", false),
("http://[2001:db8::1]:8084", "[2001:db8::1]:8084", false),
("https://aether.test", "aether.test", true),
] {
assert_eq!(
refresh_cookie_secure_for_request(
&headers(Some(origin), None),
Some(host),
false,
None,
None,
true,
),
secure,
"{origin}",
);
}
assert!(refresh_cookie_secure_for_request(
&headers(Some("https://aether.test"), None),
Some("aether.test"),
false,
None,
None,
false,
));
}
#[test]
fn refresh_cookie_does_not_infer_http_from_other_or_invalid_origins() {
for origin in [
"http://other.test",
"http://aether.test:8085",
"null",
"http://[email protected]:8084",
"http://aether.test:8084/path",
"http://aether.test:8084?query",
"http://aether.test:8084#fragment",
"http://aether.test:8084, https://aether.test:8084",
"file:///tmp/test",
] {
assert!(
refresh_cookie_secure_for_request(
&headers(Some(origin), None),
Some("aether.test:8084"),
false,
None,
None,
true,
),
"{origin}"
);
}
let mut duplicate = headers(Some("http://aether.test:8084"), None);
duplicate.append(
header::ORIGIN,
HeaderValue::from_static("https://aether.test:8084"),
);
assert!(refresh_cookie_secure_for_request(
&duplicate,
Some("aether.test:8084"),
false,
None,
None,
true,
));
}
#[test]
fn refresh_cookie_only_trusts_forwarded_protocol_from_trusted_peers() {
for (proto, trusted, secure) in [
("http", true, false),
("https", true, true),
("http", false, true),
("https", false, true),
("https, http", true, false),
("http, https", true, true),
("ftp", true, true),
("http,", true, true),
] {
assert_eq!(
refresh_cookie_secure_for_request(
&headers(None, Some(proto)),
Some("aether.test"),
trusted,
None,
None,
true,
),
secure,
"{proto}, trusted={trusted}"
);
}
let mut chained = headers(None, Some("http, http"));
chained.append("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(refresh_cookie_secure_for_request(
&chained,
Some("aether.test"),
true,
None,
None,
true,
));
}
#[test]
fn refresh_cookie_https_evidence_prevents_automatic_downgrade() {
for (origin, proto, public_url) in [
("https://aether.test", "http", None),
("http://aether.test", "https", None),
("http://aether.test", "http", Some("https://aether.test")),
] {
assert!(refresh_cookie_secure_for_request(
&headers(Some(origin), Some(proto)),
Some("aether.test"),
true,
None,
public_url,
true,
));
}
}
#[test]
fn refresh_cookie_preserves_explicit_overrides_and_unknown_defaults() {
for (explicit, secure) in [
("true", true),
("FALSE", false),
("invalid", true),
("", true),
] {
assert_eq!(
refresh_cookie_secure_for_request(
&headers(Some("http://aether.test"), None),
Some("aether.test"),
false,
Some(explicit),
None,
true,
),
secure
);
}
assert!(!refresh_cookie_secure_for_request(
&headers(Some("https://aether.test"), None),
Some("aether.test"),
false,
Some("false"),
None,
true,
));
for fallback in [false, true] {
assert_eq!(
refresh_cookie_secure_for_request(
&HeaderMap::new(),
Some("aether.test"),
false,
None,
None,
fallback,
),
fallback
);
}
}
#[test]
fn refresh_cookie_accepts_an_explicit_public_http_origin() {
assert!(!refresh_cookie_secure_for_request(
&HeaderMap::new(),
Some("internal:8084"),
false,
None,
Some("http://aether.test"),
true,
));
assert!(refresh_cookie_secure_for_request(
&headers(Some("https://aether.test"), None),
Some("internal:8084"),
false,
None,
Some("http://aether.test"),
true,
));
}
#[test]
fn refresh_cookie_rewrite_preserves_secret_path_expiry_and_httponly() {
let mut cookie = HeaderValue::from_static(
"aether_refresh_token=secret; Path=/api/auth; HttpOnly; SameSite=None; Max-Age=604800; Secure",
);
cookie.set_sensitive(true);
let rewritten = rewrite_refresh_cookie(&cookie, "aether_refresh_token", false);
assert_eq!(
rewritten.to_str().unwrap(),
"aether_refresh_token=secret; Path=/api/auth; HttpOnly; SameSite=Lax; Max-Age=604800"
);
assert!(rewritten.is_sensitive());
assert_eq!(
rewrite_refresh_cookie(&cookie, "aether_refresh_token", true),
cookie
);
}
#[test]
fn refresh_cookie_rewrite_also_clears_http_cookies() {
let cookie = HeaderValue::from_static(
"aether_refresh_token=; Path=/api/auth; HttpOnly; SameSite=None; Max-Age=0; Secure",
);
assert_eq!(
rewrite_refresh_cookie(&cookie, "aether_refresh_token", false)
.to_str()
.unwrap(),
"aether_refresh_token=; Path=/api/auth; HttpOnly; SameSite=Lax; Max-Age=0"
);
}
#[test]
fn refresh_cookie_rewrite_preserves_other_cookies_and_strict_policy() {
let unrelated = HeaderValue::from_static("oauth_binding=secret; Path=/; Secure; HttpOnly");
assert_eq!(
rewrite_refresh_cookie(&unrelated, "aether_refresh_token", false),
unrelated
);
let strict = HeaderValue::from_static(
"custom_refresh=secret; Path=/api/auth; HttpOnly; SameSite=Strict",
);
assert_eq!(
rewrite_refresh_cookie(&strict, "custom_refresh", false),
strict
);
assert!(rewrite_refresh_cookie(&strict, "custom_refresh", true)
.to_str()
.unwrap()
.ends_with("; Secure"));
let prefixed =
HeaderValue::from_static("__Secure-refresh=secret; HttpOnly; SameSite=None; Secure");
assert_eq!(
rewrite_refresh_cookie(&prefixed, "__Secure-refresh", false),
prefixed
);
}
}
@@ -248,7 +248,7 @@ pub(super) fn auth_verification_send_cooldown_seconds() -> i64 {
.unwrap_or(60)
}
pub(super) fn auth_refresh_cookie_name() -> String {
pub(crate) fn auth_refresh_cookie_name() -> String {
std::env::var("AUTH_REFRESH_COOKIE_NAME")
.ok()
.map(|value| value.trim().to_string())
@@ -31,7 +31,11 @@ fn apply_frontdoor_cors_headers(
);
headers.insert(
http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
HeaderValue::from_static("*"),
HeaderValue::from_static(if headers.contains_key("x-aether-body-field") {
"*, X-Aether-Body-Encoding, X-Aether-Body-Field, X-Aether-Body-Error, X-Aether-Usage-Id"
} else {
"*"
}),
);
if let Some(value) = requested_headers {
if let Ok(value) = HeaderValue::from_str(value) {
@@ -109,3 +113,36 @@ pub(crate) async fn frontdoor_cors_middleware(
);
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_body_headers_are_explicitly_exposed_for_credentialed_requests() {
let cors =
FrontdoorCorsConfig::new(vec!["https://console.example".to_string()], true).unwrap();
let mut headers = http::HeaderMap::new();
headers.insert(
"x-aether-body-field",
HeaderValue::from_static("request_body"),
);
apply_frontdoor_cors_headers(&mut headers, &cors, "https://console.example", None);
let exposed = headers[http::header::ACCESS_CONTROL_EXPOSE_HEADERS]
.to_str()
.unwrap()
.to_ascii_lowercase();
for name in [
"x-aether-body-encoding",
"x-aether-body-field",
"x-aether-body-error",
"x-aether-usage-id",
] {
assert!(exposed.split(',').any(|header| header.trim() == name));
}
assert_eq!(
headers[http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS],
"true"
);
}
}
@@ -5010,6 +5010,7 @@ fn retired_api_format_occurrences_are_whitelisted() {
"crates/aether-ai/formats/src/formats/registry.rs",
"crates/aether-data/runtime/src/migrate.rs",
"crates/aether-data/runtime/src/lifecycle/migrate/tests.rs",
"crates/aether-data/runtime/src/lifecycle/migrate/tests/policy_nulls.rs",
"crates/aether-usage/runtime/src/report.rs",
"frontend/src/api/endpoints/types/__tests__/api-format.spec.ts",
"frontend/src/views/admin/module-management/modelDirectivesConfig.ts",
@@ -8,7 +8,7 @@ use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
use aether_data_contracts::repository::global_models::{
AdminProviderModelListQuery, GlobalModelReadRepository,
AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogReadRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
@@ -20,8 +20,9 @@ use http::StatusCode;
use serde_json::json;
use super::super::super::{
build_router_with_state, build_state_with_execution_runtime_override, sample_bound_auth_config,
sample_bound_key, sample_endpoint, sample_key, sample_proxy_node, start_server, AppState,
build_router_with_state, build_state_with_execution_runtime_override,
sample_admin_global_model, sample_bound_auth_config, sample_bound_key, sample_endpoint,
sample_key, sample_proxy_node, start_server, AppState,
};
use crate::constants::{
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
@@ -2421,7 +2422,15 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
)],
vec![key],
));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::default());
let existing_global_model = sample_admin_global_model(
"global-claude-sonnet-4",
"claude-sonnet-4",
"Claude Sonnet 4",
);
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::default()
.with_admin_global_models(vec![existing_global_model.clone()]),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
@@ -2533,7 +2542,16 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
.and_then(|value| value.get("remaining_fraction")),
Some(&json!(0.25))
);
let imported_provider_models = global_model_repository
let global_models = global_model_repository
.list_admin_global_models(&AdminGlobalModelListQuery {
limit: 100,
..Default::default()
})
.await
.expect("global models should read after quota refresh");
assert_eq!(global_models.total, 1);
assert_eq!(global_models.items, vec![existing_global_model]);
let provider_models = global_model_repository
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: "provider-antigravity".to_string(),
is_active: None,
@@ -2541,15 +2559,8 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
limit: 100,
})
.await
.expect("imported Antigravity provider models should read");
let imported_model_names = imported_provider_models
.iter()
.map(|model| model.provider_model_name.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert!(imported_model_names.contains("claude-sonnet-4"));
assert!(imported_model_names.contains("gemini-2.5-pro"));
assert!(imported_model_names.contains("gemini-3.7-flash-tiered"));
assert!(!imported_model_names.contains("chat_23310"));
.expect("Antigravity provider models should read after quota refresh");
assert!(provider_models.is_empty());
assert_eq!(
reloaded[0]
.upstream_metadata
@@ -3916,11 +3916,47 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email() {
run_admin_oauth_test(
"gateway_names_new_antigravity_oauth_account_from_google_userinfo_email",
gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_impl,
|| {
assert_antigravity_oauth_account_uses_google_userinfo_email(
"complete",
json!({
"callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}),
)
},
);
}
async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_impl() {
#[test]
fn gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email() {
run_admin_oauth_test(
"gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email",
|| {
assert_antigravity_oauth_account_uses_google_userinfo_email(
"import-refresh-token",
json!({"refresh_token": "antigravity-import-refresh-token"}),
)
},
);
}
#[test]
fn gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email() {
run_admin_oauth_test(
"gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email",
|| {
assert_antigravity_oauth_account_uses_google_userinfo_email(
"batch-import",
json!({"credentials": "antigravity-import-refresh-token"}),
)
},
);
}
async fn assert_antigravity_oauth_account_uses_google_userinfo_email(
operation: &str,
request_body: Value,
) {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
@@ -4025,15 +4061,13 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_
let response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/provider-oauth/providers/provider-antigravity/complete"
"{gateway_url}/api/admin/provider-oauth/providers/provider-antigravity/{operation}"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}))
.json(&request_body)
.send()
.await
.expect("request should succeed");
@@ -4041,9 +4075,22 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_
let status = response.status();
let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={payload}");
assert_eq!(payload["provider_type"], "antigravity");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["replaced"], false);
let account_result = if operation == "batch-import" {
assert_eq!(payload["total"], 1);
assert_eq!(payload["success"], 1, "payload={payload}");
assert_eq!(payload["failed"], 0);
assert_eq!(payload["results"][0]["status"], "success");
assert_eq!(
payload["results"][0]["key_name"],
"[email protected]"
);
&payload["results"][0]
} else {
assert_eq!(payload["provider_type"], "antigravity");
assert_eq!(payload["email"], "[email protected]");
&payload
};
assert_eq!(account_result["replaced"], false);
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
assert_eq!(*user_info_hits.lock().expect("mutex should lock"), 1);
assert_eq!(
@@ -4055,7 +4102,7 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let key_id = payload["key_id"]
let key_id = account_result["key_id"]
.as_str()
.expect("created key id should be returned")
.to_string();
@@ -2366,6 +2366,265 @@ async fn gateway_handles_admin_usage_detail_with_ref_backed_bodies() {
upstream_handle.abort();
}
fn sample_selective_body_usage() -> StoredRequestUsageAudit {
let mut usage = sample_usage_row(
"usage-selected-body",
"req-selected-body",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
120,
30,
0.3,
0.36,
DAY_1_UNIX_SECS,
);
usage.request_body = Some(json!({ "marker": "request_body" }));
usage.provider_request_body = Some(json!({ "marker": "provider_request_body" }));
usage.response_body = Some(json!({ "marker": "response_body" }));
usage.client_response_body = Some(json!({ "marker": "client_response_body" }));
usage
}
#[tokio::test]
async fn gateway_admin_usage_detail_returns_only_the_selected_body() {
let fields = [
"request_body",
"provider_request_body",
"response_body",
"client_response_body",
];
for detached in [false, true] {
let usage = sample_selective_body_usage();
let repository = if detached {
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage])
} else {
InMemoryUsageReadRepository::seed(vec![usage])
};
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
repository,
)));
for selected in fields {
let response = local_admin_usage_response(
&state, http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?include_bodies=true&body_field={selected}"),
None,
).await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
let payload: serde_json::Value =
serde_json::from_slice(&bytes).expect("json should parse");
for field in fields {
assert_eq!(payload[format!("has_{field}")], true);
if field == selected {
assert_eq!(payload[field]["marker"], selected);
} else {
assert!(
payload[field].is_null(),
"unselected {field} must not be returned"
);
}
}
assert!(payload["body_load_errors"].is_null());
assert!(payload["body_load_error_codes"].is_null());
}
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_validates_body_field_selection() {
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed(vec![sample_selective_body_usage()]),
)));
for query in [
"body_field=unknown",
"body_field=",
"include_bodies=false&body_field=request_body",
"body_format=raw",
"body_format=unknown&body_field=request_body",
"body_format=&body_field=request_body",
] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?{query}"),
None,
)
.await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"invalid query: {query}"
);
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_reads_only_selected_body_and_is_not_cacheable() {
for detached in [false, true] {
let repository = if detached {
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![
sample_selective_body_usage(),
])
} else {
InMemoryUsageReadRepository::seed(vec![sample_selective_body_usage()])
};
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(repository)),
);
for field in [
"request_body",
"provider_request_body",
"response_body",
"client_response_body",
] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?body_field={field}&body_format=raw"),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["cache-control"],
"no-store, no-transform"
);
assert_eq!(
response.headers()["x-aether-usage-id"],
"usage-selected-body"
);
assert_eq!(response.headers()["x-aether-body-field"], field);
assert_eq!(response.headers()["x-aether-body-encoding"], "json");
assert!(response.extensions().get::<AdminAuditEvent>().is_some());
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({ "marker": field })
);
}
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_rejects_foreign_refs_and_disabled_capture() {
for body_state in [
UsageBodyCaptureState::Reference,
UsageBodyCaptureState::Disabled,
] {
let mut usage = sample_selective_body_usage();
usage.response_body = None;
usage.response_body_ref = Some("usage://request/foreign-request/response_body".to_string());
usage.response_body_state = Some(body_state);
let mut foreign = sample_selective_body_usage();
foreign.id = "foreign-usage".to_string();
foreign.request_id = "foreign-request".to_string();
foreign.response_body = Some(json!({ "secret": "must not leak" }));
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage, foreign]),
)),
);
let response = local_admin_usage_response(
&state,
http::Method::GET,
"/api/admin/usage/usage-selected-body?body_field=response_body&body_format=raw",
None,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["x-aether-body-error"], "missing");
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("secret"));
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_preserves_authorization_and_binary_headers() {
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![
sample_selective_body_usage(),
]),
)),
);
let gateway =
build_router_with_state(state).layer(tower_http::compression::CompressionLayer::new());
let (url, server) = start_server(gateway).await;
let client = reqwest::Client::new();
let endpoint = format!(
"{url}/api/admin/usage/usage-selected-body?body_field=response_body&body_format=raw"
);
let unauthorized = client.get(&endpoint).send().await.unwrap();
assert!(matches!(
unauthorized.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
));
let response = admin_request(client.get(&endpoint))
.header("accept-encoding", "gzip")
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["content-encoding"], "identity");
assert_eq!(response.headers()["x-aether-body-encoding"], "json");
assert_eq!(
response.headers()["cache-control"],
"no-store, no-transform"
);
let bytes = response.bytes().await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({ "marker": "response_body" })
);
server.abort();
}
#[tokio::test]
async fn gateway_admin_usage_detail_isolates_missing_body_errors() {
let mut usage = sample_selective_body_usage();
usage.request_body = None;
usage.request_body_ref = Some("usage://request/req-selected-body/request_body".to_string());
usage.request_body_state = Some(UsageBodyCaptureState::Reference);
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage]),
)));
for selected in ["response_body", "request_body"] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?body_field={selected}"),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json should parse");
if selected == "request_body" {
assert_eq!(payload["body_load_errors"]["request_body"], true);
assert_eq!(payload["body_load_error_codes"]["request_body"], "missing");
assert!(payload["request_body"].is_null());
} else {
assert_eq!(payload["response_body"]["marker"], "response_body");
assert!(payload["body_load_errors"].is_null());
assert!(payload["body_load_error_codes"].is_null());
}
}
}
#[tokio::test]
async fn gateway_resolves_admin_usage_detail_when_inline_state_has_body_ref() {
let (_upstream_url, upstream_hits, upstream_handle) =
@@ -50,6 +50,8 @@ use chrono::{TimeZone, Utc};
const TEST_EMAIL_VERIFICATION_TOKEN: &str =
"test-email-verification-token-00000000000000000000000000000000";
#[path = "public_support/auth_cookie.rs"]
mod auth_cookie;
#[path = "public_support/dashboard.rs"]
mod dashboard;
#[path = "public_support/vscodex.rs"]
@@ -0,0 +1,124 @@
use super::{sample_auth_user, sample_auth_wallet, start_auth_gateway_with_state};
use axum::http::{header, StatusCode};
use chrono::Utc;
use serde_json::json;
fn refresh_cookie(response: &reqwest::Response, secure: bool) -> String {
let cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(cookie.starts_with("aether_refresh_token="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("Path=/api/auth"));
assert_eq!(
cookie
.split(';')
.any(|attribute| attribute.trim() == "Secure"),
secure
);
if !secure {
assert!(!cookie.contains("SameSite=None"));
assert!(cookie.contains("SameSite=Lax"));
}
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
cookie.to_string()
}
#[tokio::test]
async fn gateway_auth_refresh_cookie_roundtrip_adapts_to_http_and_https() {
for (origin_scheme, forwarded_proto, secure) in [
("http", None, false),
("https", None, true),
("https", Some("https"), true),
("http", Some("https"), true),
] {
let now = Utc::now();
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
sample_auth_user(now),
sample_auth_wallet("user-auth-1", now),
[],
)
.await;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
header::ORIGIN,
gateway_url
.replacen("http:", &format!("{origin_scheme}:"), 1)
.parse()
.unwrap(),
);
headers.insert(
"x-client-device-id",
"cookie-roundtrip-device".parse().unwrap(),
);
if let Some(proto) = forwarded_proto {
headers.insert("x-forwarded-proto", proto.parse().unwrap());
}
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap();
let login = client.post(format!("{gateway_url}/api/auth/login"))
.json(&json!({ "email": "[email protected]", "password": "secret123", "auth_type": "local" }))
.send().await.unwrap();
assert_eq!(login.status(), StatusCode::OK);
let mut cookie = refresh_cookie(&login, secure);
for _ in 0..3 {
let refreshed = client
.post(format!("{gateway_url}/api/auth/refresh"))
.header(header::COOKIE, cookie.split(';').next().unwrap())
.send()
.await
.unwrap();
assert_eq!(refreshed.status(), StatusCode::OK);
let rotated = refresh_cookie(&refreshed, secure);
assert_ne!(rotated, cookie);
cookie = rotated;
let payload: serde_json::Value = refreshed.json().await.unwrap();
let current_user = client
.get(format!("{gateway_url}/api/auth/me"))
.bearer_auth(payload["access_token"].as_str().unwrap())
.send()
.await
.unwrap();
assert_eq!(current_user.status(), StatusCode::OK);
}
let logout = client
.post(format!("{gateway_url}/api/auth/logout"))
.header(header::COOKIE, cookie.split(';').next().unwrap())
.send()
.await
.unwrap();
assert_eq!(logout.status(), StatusCode::OK);
assert!(refresh_cookie(&logout, secure).contains("Max-Age=0"));
let revoked = client
.post(format!("{gateway_url}/api/auth/refresh"))
.header(header::COOKIE, cookie.split(';').next().unwrap())
.send()
.await
.unwrap();
assert_eq!(revoked.status(), StatusCode::UNAUTHORIZED);
assert!(refresh_cookie(&revoked, secure).contains("Max-Age=0"));
let missing = client
.post(format!("{gateway_url}/api/auth/refresh"))
.send()
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
assert!(refresh_cookie(&missing, secure).contains("Max-Age=0"));
assert_eq!(*upstream_hits.lock().unwrap(), 0);
gateway_handle.abort();
upstream_handle.abort();
}
}
+2 -2
View File
@@ -475,7 +475,7 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im
);
assert_eq!(
stored_usage.request_headers.as_ref().unwrap()["authorization"],
"[redacted]"
"Bearer sk-client-openai-local-report-sync-deep"
);
gateway_handle.abort();
@@ -2094,7 +2094,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
assert!(stored_usage.error_message.is_none());
assert_eq!(
stored_usage.request_headers.as_ref().unwrap()["authorization"],
"[redacted]"
"Bearer sk-client-claude-cli-usage-local-miss"
);
assert!(stored_usage.request_body.is_none());
assert!(stored_usage.request_body_ref.is_none());
@@ -3398,6 +3398,60 @@ mod tests {
));
}
#[test]
fn detail_payload_preserves_original_headers_with_or_without_bodies() {
let item = StoredRequestUsageAudit {
request_headers: Some(json!({
"authorization": "Bearer original-client-token",
"originator": "codex-cli",
"session-id": "original-session",
"thread-id": "original-thread",
"x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}",
"x-openai-subagent": "reviewer"
})),
provider_request_headers: Some(json!({
"authorization": "Bearer original-provider-token",
"x-api-key": "original-provider-key"
})),
response_headers: Some(json!({
"set-cookie": ["session=original-upstream", "preference=original"],
"x-upstream-custom": "original-upstream-value"
})),
client_response_headers: Some(json!({
"set-cookie": "session=original-client",
"x-client-custom": "original-client-value"
})),
..sample_usage("completed", Some(200), None)
};
for include_bodies in [false, true] {
let payload = build_admin_usage_detail_payload(
&item,
&BTreeMap::new(),
&BTreeMap::new(),
false,
false,
None,
include_bodies,
None,
&BTreeMap::new(),
);
for (field, expected) in [
("request_headers", &item.request_headers),
("provider_request_headers", &item.provider_request_headers),
("response_headers", &item.response_headers),
("client_response_headers", &item.client_response_headers),
] {
assert_eq!(
&payload[field],
expected.as_ref().unwrap(),
"{field} should retain original values when include_bodies={include_bodies}"
);
}
}
}
#[test]
fn detail_payload_marks_reference_backed_bodies_as_available() {
let item = StoredRequestUsageAudit {
+34 -1
View File
@@ -20,7 +20,7 @@ pub fn build_kiro_batch_import_key_name(
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("kiro_{}", &hex[..6])
format!("账号_{}", &hex[..6])
});
format!("{base} ({method})")
}
@@ -130,3 +130,36 @@ pub fn parse_admin_provider_oauth_kiro_batch_import_entries(raw_credentials: &st
.map(|refresh_token| json!({ "refreshToken": refresh_token }))
.collect()
}
#[cfg(test)]
mod tests {
use super::build_kiro_batch_import_key_name;
#[test]
fn kiro_batch_import_key_name_preserves_email_and_auth_method() {
for method in ["social", "idc"] {
assert_eq!(
build_kiro_batch_import_key_name(
Some(" [email protected] "),
Some(method),
Some("refresh-token-1"),
),
format!("[email protected] ({method})")
);
}
}
#[test]
fn kiro_batch_import_key_name_without_email_uses_generic_account_prefix() {
for email in [None, Some(""), Some(" ")] {
assert_eq!(
build_kiro_batch_import_key_name(email, None, Some("refresh-token-1")),
"账号_154f43 (social)"
);
assert_eq!(
build_kiro_batch_import_key_name(email, Some("idc"), Some("refresh-token-1")),
"账号_154f43 (idc)"
);
}
}
}
+24 -2
View File
@@ -405,18 +405,40 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
.collect::<String>()
})
.unwrap_or_else(|| "unknown".to_string());
format!("kiro_{fallback} (idc)")
format!("账号_{fallback} (idc)")
}
#[cfg(test)]
mod tests {
use super::{
decode_jwt_claims, enrich_admin_provider_oauth_auth_config,
build_kiro_device_key_name, decode_jwt_claims, enrich_admin_provider_oauth_auth_config,
parse_provider_oauth_callback_params, MAX_UNVERIFIED_JWT_CLAIMS_BYTES,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::json;
#[test]
fn kiro_device_key_name_preserves_email_and_auth_method() {
assert_eq!(
build_kiro_device_key_name(Some(" [email protected] "), Some("refresh-token-1")),
"[email protected] (idc)"
);
}
#[test]
fn kiro_device_key_name_without_email_uses_generic_account_prefix() {
for email in [None, Some(""), Some(" ")] {
assert_eq!(
build_kiro_device_key_name(email, Some("refresh-token-1")),
"账号_154f43 (idc)"
);
assert_eq!(
build_kiro_device_key_name(email, None),
"账号_unknown (idc)"
);
}
}
fn sample_unsigned_jwt(payload: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
@@ -18,8 +18,6 @@ futures-util.workspace = true
sqlx = { workspace = true, features = ["postgres", "runtime-tokio-rustls", "chrono", "migrate", "macros"] }
serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio.workspace = true
@@ -0,0 +1,45 @@
DO $migration$
DECLARE
policy_column record;
BEGIN
FOR policy_column IN
SELECT *
FROM (VALUES
('api_keys', 'allowed_providers'),
('api_keys', 'allowed_api_formats'),
('api_keys', 'allowed_models'),
('api_keys', 'ip_rules'),
('users', 'allowed_providers'),
('users', 'allowed_api_formats'),
('users', 'allowed_models'),
('user_groups', 'allowed_providers'),
('user_groups', 'allowed_api_formats'),
('user_groups', 'allowed_models'),
('provider_api_keys', 'api_formats'),
('provider_api_keys', 'allowed_models')
) AS policy_columns(table_name, column_name)
LOOP
EXECUTE format(
$statement$
UPDATE public.%1$I
SET %2$I = NULL
WHERE json_typeof(%2$I::json) = 'null'
OR (
json_typeof(%2$I::json) = 'string'
AND (%2$I::json #>> '{}') ~* '^[[:space:]]*(null)?[[:space:]]*$'
)
$statement$,
policy_column.table_name,
policy_column.column_name
);
END LOOP;
END;
$migration$;
UPDATE public.management_tokens
SET allowed_ips = NULL
WHERE json_typeof(allowed_ips::json) = 'null';
UPDATE public.management_tokens
SET permissions = NULL
WHERE json_typeof(permissions::json) = 'null';
@@ -1,9 +1,9 @@
use aether_data_contracts::repository::usage::{
canonical_usage_body_ref_for, parse_usage_body_ref, read_decompressed_usage_json,
usage_body_ref, ApiKeyLastUsedDelta, ManagementTokenCounterDelta, ProxyNodeCounterDelta,
StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBreakdownSummaryRow,
StoredUsageCacheAffinityHitSummary, StoredUsageCacheAffinityIntervalRow,
StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBodyPayload,
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
StoredUsageDashboardStatsSummary, StoredUsageDashboardSummary, StoredUsageErrorDistributionRow,
StoredUsageLeaderboardSummary, StoredUsagePerformancePercentilesRow,
@@ -63,8 +63,25 @@ pub mod cleanup;
// newly captured bodies always spill to usage_body_blobs and resolve through usage_http_audits.
const MAX_INLINE_USAGE_BODY_BYTES: usize = 0;
const MAX_SUPPORTED_UNIX_SECS: u64 = 253_402_300_799;
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT payload_gzip FROM usage_body_blobs WHERE body_ref = $1 AND request_id = $2 AND body_field = $3 LIMIT 1"#;
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT CASE WHEN octet_length(payload_gzip) <= $4 THEN payload_gzip END AS payload_gzip FROM usage_body_blobs WHERE body_ref = $1 AND request_id = $2 AND body_field = $3 LIMIT 1"#;
const DELETE_USAGE_BODY_BLOB_SQL: &str = include_str!("queries/delete_usage_body_blob_sql.sql");
static USAGE_BODY_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
async fn decode_usage_body_in_background(
decode: impl FnOnce() -> Result<Option<Value>, DataLayerError> + Send + 'static,
) -> Result<Option<Value>, DataLayerError> {
let permit = USAGE_BODY_DECODE_SLOTS.acquire().await.map_err(|error| {
DataLayerError::UnexpectedValue(format!("usage body decoder unavailable: {error}"))
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
decode()
})
.await
.map_err(|error| {
DataLayerError::UnexpectedValue(format!("usage body decoder failed: {error}"))
})?
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct AggregateRangeSplit {
@@ -2862,36 +2879,82 @@ ORDER BY request_count DESC, "usage".provider_name ASC
Ok(items)
}
pub async fn resolve_body_ref(&self, body_ref: &str) -> Result<Option<Value>, DataLayerError> {
pub async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, DataLayerError> {
let json_limit =
aether_data_contracts::repository::usage::MAX_DECOMPRESSED_USAGE_JSON_BYTES as i64;
let encoded_limit = json_limit + 1024 * 1024;
let Some((request_id, field)) = parse_usage_body_ref(body_ref) else {
return Ok(None);
};
let canonical_ref = usage_body_ref(&request_id, field);
let blob_row = sqlx::query(FIND_USAGE_BODY_BLOB_BY_REF_SQL)
let row = sqlx::query(FIND_USAGE_BODY_BLOB_BY_REF_SQL)
.bind(&canonical_ref)
.bind(&request_id)
.bind(field.as_storage_field())
.bind(encoded_limit)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
if let Some(row) = blob_row.as_ref() {
let payload_gzip = row
.try_get::<Vec<u8>, _>("payload_gzip")
.map_postgres_err()?;
return inflate_usage_json_value(&payload_gzip).map(Some);
if let Some(row) = row {
return row
.try_get::<Option<Vec<u8>>, _>("payload_gzip")
.map_postgres_err()?
.map(|bytes| Some(StoredUsageBodyPayload::Gzip(bytes)))
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"encoded usage json exceeds {encoded_limit} bytes"
))
});
}
let (inline_column, compressed_column) = usage_body_sql_columns(field);
let row = sqlx::query(&format!(
"SELECT {inline_column} AS inline_body, {compressed_column} AS compressed_body FROM \"usage\" WHERE request_id = $1 LIMIT 1"
"SELECT CASE WHEN octet_length({inline_column}::text) <= $2 THEN {inline_column}::text END AS inline_body, CASE WHEN octet_length({compressed_column}) <= $3 THEN {compressed_column} END AS compressed_body, (COALESCE(octet_length({inline_column}::text) > $2, false) OR ({inline_column} IS NULL AND COALESCE(octet_length({compressed_column}) > $3, false))) AS too_large FROM \"usage\" WHERE request_id = $1 LIMIT 1"
))
.bind(request_id)
.bind(json_limit)
.bind(encoded_limit)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref()
.map(|row| usage_json_column(row, "inline_body", "compressed_body", true))
.transpose()
.map(|value| value.and_then(|column| column.value))
let Some(row) = row else {
return Ok(None);
};
if row.try_get::<bool, _>("too_large").map_postgres_err()? {
return Err(DataLayerError::UnexpectedValue(
"encoded usage json exceeds preview limit".to_string(),
));
}
if let Some(body) = row
.try_get::<Option<String>, _>("inline_body")
.map_postgres_err()?
{
return Ok(Some(StoredUsageBodyPayload::Json(body.into_bytes())));
}
Ok(row
.try_get::<Option<Vec<u8>>, _>("compressed_body")
.map_postgres_err()?
.map(StoredUsageBodyPayload::Gzip))
}
pub async fn resolve_body_ref(&self, body_ref: &str) -> Result<Option<Value>, DataLayerError> {
let Some(payload) = self.read_body_payload(body_ref).await? else {
return Ok(None);
};
decode_usage_body_in_background(move || match payload {
StoredUsageBodyPayload::Gzip(bytes) => inflate_usage_json_value(&bytes).map(Some),
StoredUsageBodyPayload::Json(bytes) => {
let bytes = read_decompressed_usage_json(std::io::Cursor::new(bytes))?;
serde_json::from_slice(&bytes).map(Some).map_err(|error| {
DataLayerError::UnexpectedValue(format!(
"failed to parse decompressed usage json: {error}"
))
})
}
})
.await
}
async fn hydrate_usage_body_refs(
@@ -10306,6 +10369,13 @@ impl UsageReadRepository for SqlxUsageReadRepository {
Self::resolve_body_ref(self, body_ref).await
}
async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, DataLayerError> {
Self::read_body_payload(self, body_ref).await
}
async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
@@ -283,11 +283,11 @@ async fn live_full_http_capture_round_trips_for_direct_and_batch_writes() {
.unwrap();
assert_eq!(
stored.request_headers,
Some(json!({"content-type": "application/json", "authorization": "[redacted]"}))
Some(json!({"content-type": "application/json", "authorization": "Bearer private"}))
);
assert_eq!(
stored.response_headers,
Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"}))
Some(json!({"content-type": "text/event-stream", "set-cookie": "private"}))
);
for (field, expected) in [
(UsageBodyField::RequestBody, pending.request_body.as_ref()),
@@ -704,7 +704,7 @@ async fn live_pending_batch_persists_auxiliary_state_and_preserves_terminal_conf
.unwrap();
assert_eq!(
captured.request_headers,
Some(json!({"x-request": "[redacted]"}))
Some(json!({"x-request": "request-value"}))
);
assert_eq!(
repository
@@ -4274,6 +4274,38 @@ fn prepare_usage_body_storage_detaches_small_payloads_into_blob_storage() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn usage_body_decode_does_not_block_the_async_runtime_thread() {
let runtime_thread = std::thread::current().id();
let payload = json!({"message": "background decoding"});
let compressed = prepare_usage_body_storage(Some(&payload))
.expect("body should compress")
.detached_blob_bytes
.expect("body should be detached");
let decoded = super::decode_usage_body_in_background(move || {
assert_ne!(std::thread::current().id(), runtime_thread);
inflate_usage_json_value(&compressed).map(Some)
})
.await
.expect("body should decode");
assert_eq!(decoded, Some(payload));
}
#[tokio::test]
async fn usage_body_decode_preserves_storage_decode_errors() {
let error = super::decode_usage_body_in_background(|| {
inflate_usage_json_value(b"invalid gzip").map(Some)
})
.await
.expect_err("corrupt bodies should fail");
assert!(error
.to_string()
.contains("failed to decompress usage json:"));
}
#[test]
fn prepare_usage_body_storage_compresses_large_payloads() {
let payload = json!({
@@ -16,7 +16,7 @@ pub use types::{
ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary,
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageDailySummary, StoredUsageDashboardDailyBreakdownRow,
StoredUsageDashboardProviderCount, StoredUsageDashboardStatsSummary,
@@ -367,31 +367,8 @@ pub fn sanitize_usage_capture_controls_for_persistence(
usage
}
pub fn usage_header_value_is_sensitive(name: &str) -> bool {
![
"accept",
"accept-encoding",
"content-encoding",
"content-length",
"content-type",
"transfer-encoding",
"x-request-id",
"x-trace-id",
]
.iter()
.any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
}
pub fn sanitize_usage_headers_for_persistence(value: Option<Value>) -> Option<Value> {
let Value::Object(mut headers) = value? else {
return None;
};
for (name, value) in &mut headers {
if usage_header_value_is_sensitive(name) && !value.is_null() {
*value = Value::String("[redacted]".to_string());
}
}
Some(Value::Object(headers))
value.filter(Value::is_object)
}
fn sanitize_usage_routing_fields(
@@ -833,6 +810,35 @@ mod tests {
);
}
#[test]
fn header_capture_preserves_original_values() {
let headers = json!({
"Authorization": "Bearer original-token",
"originator": "codex-cli",
"user-agent": "codex-tui/test",
"session-id": "session-original",
"thread-id": "thread-original",
"x-client-request-id": "client-request-original",
"x-codex-beta-features": "feature-original",
"x-codex-parent-thread-id": "parent-thread-original",
"x-codex-turn-metadata": "{\"turn_id\":\"turn-original\"}",
"x-codex-turn-state": "turn-state-original",
"x-codex-window-id": "window-original",
"x-openai-internal-codex-responses-lite": "true",
"x-openai-subagent": "reviewer",
"set-cookie": ["session=original", "preference=original"],
"x-custom-header": "original-value",
"x-null": null
});
assert_eq!(
super::sanitize_usage_headers_for_persistence(Some(headers.clone())),
Some(headers)
);
for invalid in [json!(null), json!("headers"), json!(["headers"])] {
assert!(super::sanitize_usage_headers_for_persistence(Some(invalid)).is_none());
}
}
#[test]
fn auxiliary_capture_projection_preserves_captures_and_honors_disabled_states() {
let mut input = usage_with_http_capture();
@@ -843,7 +849,7 @@ mod tests {
assert_eq!(
usage.request_headers,
Some(json!({"authorization": "[redacted]"}))
Some(json!({"authorization": "Bearer secret"}))
);
assert!(usage.request_body.is_none());
assert!(usage.request_body_ref.is_none());
@@ -896,15 +902,19 @@ mod tests {
assert!(captured.client_response_body_ref.is_none());
assert_eq!(
captured.request_headers,
Some(json!({"Content-Type": "application/json", "Authorization": "[redacted]"}))
Some(json!({"Content-Type": "application/json", "Authorization": "Bearer secret"}))
);
assert_eq!(
captured.provider_request_headers,
Some(json!({"x-api-key": "[redacted]"}))
Some(json!({"x-api-key": "secret"}))
);
assert_eq!(
captured.response_headers,
Some(json!({"set-cookie": "[redacted]"}))
Some(json!({"set-cookie": "secret"}))
);
assert_eq!(
captured.client_response_headers,
input.client_response_headers
);
assert!(captured.error_message.is_none());
}
@@ -1732,6 +1732,12 @@ pub fn canonical_usage_body_ref_for(
.map(|(request_id, field)| usage_body_ref(&request_id, field))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoredUsageBodyPayload {
Gzip(Vec<u8>),
Json(Vec<u8>),
}
#[async_trait]
pub trait UsageReadRepository: Send + Sync {
async fn find_by_id(
@@ -1761,6 +1767,20 @@ pub trait UsageReadRepository: Send + Sync {
body_ref: &str,
) -> Result<Option<Value>, crate::DataLayerError>;
async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, crate::DataLayerError> {
self.resolve_body_ref(body_ref)
.await?
.map(|value| {
serde_json::to_vec(&value)
.map(StoredUsageBodyPayload::Json)
.map_err(|error| crate::DataLayerError::UnexpectedValue(error.to_string()))
})
.transpose()
}
async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
@@ -27,6 +27,8 @@ use crate::lifecycle::bootstrap::postgres::{
EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION, EMPTY_DATABASE_SNAPSHOT_SQL,
};
mod policy_nulls;
/// A clean PostgreSQL database is bootstrapped from the schema snapshot first;
/// migrations after the privacy/security frontier are intentionally left
/// pending so their data-preserving changes still execute. Exercise the same
@@ -1572,6 +1574,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260831030000,
20260901000000,
20260903000000,
20260908000000,
]
);
}
@@ -0,0 +1,380 @@
use aether_data_contracts::repository::{
auth::AuthApiKeyLookupKey, management_tokens::ManagementTokenReadRepository,
};
use aether_data_postgres::{
SqlxAuthApiKeySnapshotReadRepository, SqlxManagementTokenRepository, SqlxUserReadRepository,
};
use serde_json::{json, Value};
use sqlx::{migrate::Migrate, query, query_scalar, Connection, PgConnection, PgPool};
use super::{prepare_database_for_startup, ManagedPostgresServer, POSTGRES_MIGRATOR};
use crate::lifecycle::migrate::run_migrations;
const POLICY_NULL_MIGRATION_VERSION: i64 = 20260908000000;
const POLICY_COLUMNS: &[(&str, &[&str])] = &[
(
"api_keys",
&[
"allowed_providers",
"allowed_api_formats",
"allowed_models",
"ip_rules",
],
),
(
"users",
&["allowed_providers", "allowed_api_formats", "allowed_models"],
),
(
"user_groups",
&["allowed_providers", "allowed_api_formats", "allowed_models"],
),
("provider_api_keys", &["api_formats", "allowed_models"]),
("management_tokens", &["allowed_ips", "permissions"]),
];
#[tokio::test]
async fn postgres_policy_null_migration_preserves_non_null_policies_and_is_idempotent() {
let migration = POSTGRES_MIGRATOR
.iter()
.find(|migration| migration.version == POLICY_NULL_MIGRATION_VERSION)
.expect("legacy policy null migration should be embedded");
let legacy_values = [
Value::Null,
json!("null"),
json!(" NULL "),
json!("\tNuLl\r\n"),
json!(""),
json!(" \t\r\n"),
];
let preserved_values = [
json!([]),
json!(["provider-allowed"]),
json!(["openai:chat", "claude:chat"]),
json!(["127.0.0.1/32"]),
json!(["null"]),
json!("provider-allowed"),
json!("[\"provider-allowed\"]"),
json!("null-provider"),
json!("nullnull"),
json!([null]),
json!([" "]),
json!([1]),
json!({}),
json!({"policy": null}),
json!(true),
json!(42),
];
let cases = std::iter::once((None, None))
.chain(legacy_values.into_iter().map(|value| (Some(value), None)))
.chain(
preserved_values
.into_iter()
.map(|value| (Some(value.clone()), Some(value))),
)
.collect::<Vec<_>>();
for storage_type in ["json", "jsonb"] {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres policy null test should start or skip")
else {
return;
};
let pool = PgPool::connect(server.database_url())
.await
.expect("policy fixture pool should connect");
for &(table_name, columns) in POLICY_COLUMNS {
let definitions = columns
.iter()
.map(|column| format!("{column} {storage_type}"))
.collect::<Vec<_>>()
.join(", ");
let modes = if matches!(table_name, "users" | "user_groups") {
", allowed_providers_mode text DEFAULT 'deny_all', \
allowed_api_formats_mode text DEFAULT 'specific', \
allowed_models_mode text DEFAULT 'inherit'"
} else {
""
};
query(&format!(
"CREATE TABLE public.{table_name} \
(id integer PRIMARY KEY, {definitions}, metadata {storage_type}{modes})"
))
.execute(&pool)
.await
.expect("policy fixture table should be created");
let placeholders = vec![format!("$2::{storage_type}"); columns.len()].join(", ");
let insert_sql = format!(
"INSERT INTO public.{table_name} (id, {}, metadata) \
VALUES ($1, {placeholders}, 'null'::{storage_type})",
columns.join(", ")
);
for (case_index, (input, _)) in cases.iter().enumerate() {
query(&insert_sql)
.bind(i32::try_from(case_index).expect("fixture index should fit i32"))
.bind(input.clone())
.execute(&pool)
.await
.expect("policy fixture should insert");
}
}
for attempt in 0..2 {
sqlx::raw_sql(&migration.sql)
.execute(&pool)
.await
.expect("legacy policy null migration should execute");
for &(table_name, columns) in POLICY_COLUMNS {
let expected_values = cases
.iter()
.map(|(input, expected)| match (table_name, input) {
("management_tokens", Some(Value::String(_))) => input.clone(),
_ => expected.clone(),
})
.collect::<Vec<_>>();
let expected_sql_null_ids = expected_values
.iter()
.enumerate()
.filter(|(_, value)| value.is_none())
.map(|(index, _)| i32::try_from(index).expect("fixture index should fit i32"))
.collect::<Vec<_>>();
for column in columns {
let actual_values = query_scalar::<_, Option<Value>>(&format!(
"SELECT {column} FROM public.{table_name} ORDER BY id"
))
.fetch_all(&pool)
.await
.expect("migrated policy values should be readable");
assert_eq!(
actual_values, expected_values,
"{storage_type}: {table_name}.{column}, attempt {attempt}"
);
let sql_null_ids = query_scalar::<_, i32>(&format!(
"SELECT id FROM public.{table_name} WHERE {column} IS NULL ORDER BY id"
))
.fetch_all(&pool)
.await
.expect("actual SQL NULL rows should be readable");
assert_eq!(
sql_null_ids, expected_sql_null_ids,
"{storage_type}: {table_name}.{column} must contain actual SQL NULL"
);
}
let metadata_preserved: bool = query_scalar(&format!(
"SELECT bool_and(metadata IS NOT NULL AND json_typeof(metadata::json) = 'null') \
FROM public.{table_name}"
))
.fetch_one(&pool)
.await
.expect("unrelated JSON null metadata should remain readable");
assert!(
metadata_preserved,
"{table_name}.metadata must not be cleared"
);
if matches!(table_name, "users" | "user_groups") {
let modes: Vec<(String, String, String)> = sqlx::query_as(&format!(
"SELECT DISTINCT allowed_providers_mode, allowed_api_formats_mode, \
allowed_models_mode FROM public.{table_name}"
))
.fetch_all(&pool)
.await
.expect("policy modes should be readable");
assert_eq!(
modes,
vec![(
"deny_all".to_string(),
"specific".to_string(),
"inherit".to_string(),
)],
"{table_name} policy modes must not change"
);
}
}
}
pool.close().await;
}
}
#[tokio::test]
async fn postgres_policy_null_migration_repairs_legacy_upgrade_before_auth_reads() {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres policy upgrade test should start or skip")
else {
return;
};
let mut connection = PgConnection::connect(server.database_url())
.await
.expect("legacy database connection should open");
connection
.ensure_migrations_table()
.await
.expect("legacy migration bookkeeping should be created");
for migration in POSTGRES_MIGRATOR
.iter()
.filter(|migration| migration.version < POLICY_NULL_MIGRATION_VERSION)
{
connection
.apply(migration)
.await
.expect("previous PostgreSQL migrations should apply");
}
drop(connection);
let pool = PgPool::connect(server.database_url())
.await
.expect("legacy database pool should connect");
sqlx::raw_sql(
r#"
INSERT INTO public.users (id, username, email_verified)
VALUES ('policy-key-owner', 'policy-key-owner', FALSE),
('legacy-policy-user', 'legacy-policy-user', FALSE);
UPDATE public.users
SET allowed_providers = 'null',
allowed_api_formats = '"null"',
allowed_models = '""',
allowed_models_mode = 'deny_all'
WHERE id = 'legacy-policy-user';
INSERT INTO public.api_keys (
id, user_id, key_hash, allowed_providers, allowed_api_formats, allowed_models, ip_rules
)
VALUES (
'legacy-policy-key', 'policy-key-owner', repeat('a', 64), 'null', '"null"', '""', 'null'
);
INSERT INTO public.user_groups (
id, name, normalized_name, allowed_providers, allowed_api_formats, allowed_models,
allowed_providers_mode, allowed_api_formats_mode, allowed_models_mode
)
VALUES (
'legacy-policy-group', 'Legacy policy', 'legacy-policy', 'null', '"null"', '""',
'deny_all', 'specific', 'inherit'
);
INSERT INTO public.management_tokens (
id, user_id, token_hash, name, allowed_ips, permissions
)
VALUES (
'legacy-policy-token', 'policy-key-owner', repeat('b', 64), 'Legacy token', 'null', 'null'
), (
'restricted-policy-token', 'policy-key-owner', repeat('c', 64), 'Restricted token',
'["127.0.0.1"]', '["admin:users:read"]'
);
"#,
)
.execute(&pool)
.await
.expect("legacy policy fixtures should insert");
let auth_repository = SqlxAuthApiKeySnapshotReadRepository::new(pool.clone());
let users_repository = SqlxUserReadRepository::new(pool.clone());
let tokens_repository = SqlxManagementTokenRepository::new(pool.clone());
let api_key_error = auth_repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
.await
.expect_err("legacy API key JSON null should reproduce the upgrade failure");
assert!(api_key_error
.to_string()
.contains("api_keys.allowed_providers contains JSON null"));
assert!(users_repository
.find_user_auth_by_id("legacy-policy-user")
.await
.expect_err("legacy user JSON null should fail strict policy decoding")
.to_string()
.contains("users.allowed_providers contains JSON null"));
assert!(users_repository
.find_user_group_by_id("legacy-policy-group")
.await
.expect_err("legacy group JSON null should fail strict policy decoding")
.to_string()
.contains("user_groups.allowed_providers contains JSON null"));
let legacy_token = tokens_repository
.get_management_token_with_user("legacy-policy-token")
.await
.expect("legacy management token should be readable")
.expect("legacy management token should exist");
assert_eq!(legacy_token.token.allowed_ips, Some(Value::Null));
assert_eq!(legacy_token.token.permissions, Some(Value::Null));
let pending = prepare_database_for_startup(&pool)
.await
.expect("legacy database startup preparation should succeed");
assert_eq!(
pending.first().map(|migration| migration.version),
Some(POLICY_NULL_MIGRATION_VERSION)
);
run_migrations(&pool)
.await
.expect("startup should normalize legacy policies");
let snapshot = auth_repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
.await
.expect("upgraded API key should decode")
.expect("upgraded API key should still exist");
assert!(snapshot.api_key_allowed_providers.is_none());
assert!(snapshot.api_key_allowed_api_formats.is_none());
assert!(snapshot.api_key_allowed_models.is_none());
assert!(snapshot.api_key_ip_rules.is_none());
let exported_keys = auth_repository
.list_export_api_keys_by_ids(&["legacy-policy-key".to_string()])
.await
.expect("upgraded API key listing should decode");
assert_eq!(exported_keys.len(), 1);
assert!(exported_keys[0].allowed_providers.is_none());
let user = users_repository
.find_user_auth_by_id("legacy-policy-user")
.await
.expect("upgraded user should decode")
.expect("upgraded user should still exist");
assert!(user.allowed_providers.is_none());
assert!(user.allowed_api_formats.is_none());
assert!(user.allowed_models.is_none());
assert_eq!(user.allowed_models_mode, "deny_all");
let group = users_repository
.find_user_group_by_id("legacy-policy-group")
.await
.expect("upgraded group should decode")
.expect("upgraded group should still exist");
assert!(group.allowed_providers.is_none());
assert!(group.allowed_api_formats.is_none());
assert!(group.allowed_models.is_none());
assert_eq!(group.allowed_providers_mode, "deny_all");
assert_eq!(group.allowed_api_formats_mode, "specific");
assert_eq!(group.allowed_models_mode, "inherit");
let legacy_token = tokens_repository
.get_management_token_with_user("legacy-policy-token")
.await
.expect("upgraded management token should decode")
.expect("upgraded management token should still exist");
assert!(legacy_token.token.allowed_ips.is_none());
assert!(legacy_token.token.permissions.is_none());
let restricted_token = tokens_repository
.get_management_token_with_user("restricted-policy-token")
.await
.expect("restricted management token should decode")
.expect("restricted management token should still exist");
assert_eq!(
restricted_token.token.allowed_ips,
Some(json!(["127.0.0.1"]))
);
assert_eq!(
restricted_token.token.permissions,
Some(json!(["admin:users:read"]))
);
run_migrations(&pool)
.await
.expect("restarting an upgraded database should be safe");
assert!(prepare_database_for_startup(&pool)
.await
.expect("upgraded database should remain current")
.is_empty());
pool.close().await;
}
@@ -155,7 +155,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() {
);
assert_eq!(
stored_pending.request_headers,
Some(json!({"content-type": "application/json", "authorization": "[redacted]"}))
Some(json!({"content-type": "application/json", "authorization": "Bearer private"}))
);
let mut streaming = sample_upsert_usage_record(&pending.request_id);
@@ -190,7 +190,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() {
);
assert_eq!(
stored_terminal.response_headers,
Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"}))
Some(json!({"content-type": "text/event-stream", "set-cookie": "private"}))
);
let found = repository
@@ -179,7 +179,8 @@ impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
ctx: &crate::provider::ProviderOAuthTransportContext,
input: crate::provider::ProviderOAuthImportInput,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner.import_credentials(executor, ctx, input).await
let result = self.inner.import_credentials(executor, ctx, input).await?;
self.enrich_google_identity(executor, ctx, result).await
}
async fn refresh(
@@ -223,10 +224,11 @@ mod tests {
use super::{AntigravityProviderOAuthAdapter, ANTIGRAVITY_USER_INFO_URL};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthImportInput,
ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::json;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::sync::Mutex;
@@ -235,6 +237,8 @@ mod tests {
#[derive(Default)]
struct GoogleOAuthExecutor {
requests: Mutex<Vec<OAuthHttpRequest>>,
token_payload: Option<Value>,
user_info_response: Option<OAuthHttpResponse>,
}
fn transport_context() -> ProviderOAuthTransportContext {
@@ -275,26 +279,36 @@ mod tests {
.expect("requests should lock")
.push(request);
match request_id.as_str() {
"provider-oauth:exchange-code" => Ok(OAuthHttpResponse {
status_code: 200,
body_text: json!({
"access_token": "google-access-token",
"refresh_token": "google-refresh-token",
"token_type": "Bearer",
"expires_in": 3600
"provider-oauth:exchange-code" | "provider-oauth:refresh-token" => {
Ok(OAuthHttpResponse {
status_code: 200,
body_text: self
.token_payload
.clone()
.unwrap_or_else(|| {
json!({
"access_token": "google-access-token",
"refresh_token": "google-refresh-token",
"token_type": "Bearer",
"expires_in": 3600
})
})
.to_string(),
json_body: None,
})
.to_string(),
json_body: None,
}),
"provider-oauth:antigravity-user-info" => Ok(OAuthHttpResponse {
status_code: 200,
body_text: json!({
"email": "[email protected]",
"verified_email": true
})
.to_string(),
json_body: None,
}),
}
"provider-oauth:antigravity-user-info" => Ok(self
.user_info_response
.clone()
.unwrap_or_else(|| OAuthHttpResponse {
status_code: 200,
body_text: json!({
"email": "[email protected]",
"verified_email": true
})
.to_string(),
json_body: None,
})),
other => panic!("unexpected OAuth request: {other}"),
}
}
@@ -365,6 +379,133 @@ mod tests {
assert_eq!(requests[1].network, ctx.network);
}
#[tokio::test]
async fn antigravity_import_fetches_google_email_for_account_identity() {
let adapter = AntigravityProviderOAuthAdapter::default()
.with_oauth_credentials_for_tests("test-client-id", "test-client-secret");
let ctx = transport_context();
let executor = GoogleOAuthExecutor::default();
let result = adapter
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: "antigravity".to_string(),
name: None,
refresh_token: Some("google-refresh-token".to_string()),
raw_credentials: None,
network: ctx.network.clone(),
},
)
.await
.expect("Antigravity OAuth import should succeed");
assert_eq!(result.auth_config["email"], "[email protected]");
assert_eq!(
result
.token_set
.raw_payload
.as_ref()
.and_then(|payload| payload.get("email")),
Some(&json!("[email protected]"))
);
let requests = executor.requests.lock().expect("requests should lock");
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].request_id, "provider-oauth:refresh-token");
assert_eq!(requests[1].url, ANTIGRAVITY_USER_INFO_URL);
assert_eq!(requests[1].method, reqwest::Method::GET);
assert_eq!(
requests[1].headers.get("authorization").map(String::as_str),
Some("Bearer google-access-token")
);
assert_eq!(requests[1].network, ctx.network);
}
#[tokio::test]
async fn antigravity_import_skips_userinfo_when_token_payload_has_email() {
let adapter = AntigravityProviderOAuthAdapter::default()
.with_oauth_credentials_for_tests("test-client-id", "test-client-secret");
let ctx = transport_context();
let executor = GoogleOAuthExecutor {
token_payload: Some(json!({
"access_token": "google-access-token",
"email": "[email protected]"
})),
..Default::default()
};
let result = adapter
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: "antigravity".to_string(),
name: None,
refresh_token: Some("google-refresh-token".to_string()),
raw_credentials: None,
network: ctx.network.clone(),
},
)
.await
.expect("Antigravity OAuth import should succeed");
assert_eq!(result.auth_config["email"], "[email protected]");
assert_eq!(
executor
.requests
.lock()
.expect("requests should lock")
.len(),
1
);
}
#[tokio::test]
async fn antigravity_import_rejects_unavailable_or_invalid_google_identity() {
let adapter = AntigravityProviderOAuthAdapter::default()
.with_oauth_credentials_for_tests("test-client-id", "test-client-secret");
let ctx = transport_context();
for (status_code, profile) in [
(401, json!({"error": "unauthorized"})),
(200, json!({})),
(200, json!({"email": " "})),
(
200,
json!({"email": "[email protected]", "verified_email": false}),
),
] {
let executor = GoogleOAuthExecutor {
user_info_response: Some(OAuthHttpResponse {
status_code,
body_text: profile.to_string(),
json_body: None,
}),
..Default::default()
};
let result = adapter
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: "antigravity".to_string(),
name: None,
refresh_token: Some("google-refresh-token".to_string()),
raw_credentials: None,
network: ctx.network.clone(),
},
)
.await;
assert!(
result.is_err(),
"invalid Google identity should reject import: {profile}"
);
}
}
#[tokio::test]
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
let adapter = AntigravityProviderOAuthAdapter::default();
+150 -6
View File
@@ -666,7 +666,8 @@ impl RollingFileSink {
config: FileLoggingConfig,
cleanup: fn(&str, &FileLoggingConfig) -> io::Result<usize>,
) -> io::Result<(Self, Option<StartupCleanupWarning>)> {
fs::create_dir_all(&config.dir)?;
fs::create_dir_all(&config.dir)
.map_err(|error| log_destination_error("create log directory", &config.dir, error))?;
let startup_cleanup_warning =
cleanup(service_name, &config)
.err()
@@ -727,13 +728,39 @@ fn open_bucketed_log_file(dir: &Path, service_name: &str, bucket: &str) -> io::R
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
}
let file = options.open(&path)?;
validate_open_log_file(&file, &path)?;
let file = options
.open(&path)
.map_err(|error| log_destination_error("open log file", &path, error))?;
validate_open_log_file(&file, &path)
.map_err(|error| log_destination_error("validate log file", &path, error))?;
Ok(file)
}
fn log_destination_error(operation: &str, path: &Path, error: io::Error) -> io::Error {
let mut message = format!("failed to {operation} {}: {error}", path.display());
#[cfg(unix)]
{
let process_uid = unsafe { libc::geteuid() };
let process_gid = unsafe { libc::getegid() };
message.push_str(&format!(" (process uid={process_uid}, gid={process_gid})"));
}
message.push_str(
"; check ownership and write permissions for the log directory and existing log files",
);
io::Error::new(error.kind(), message)
}
#[cfg(unix)]
fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
validate_open_log_file_for_user(file, path, unsafe { libc::geteuid() })
}
#[cfg(unix)]
fn validate_open_log_file_for_user(
file: &File,
path: &Path,
process_uid: libc::uid_t,
) -> io::Result<()> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let metadata = file.metadata()?;
@@ -743,12 +770,14 @@ fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
format!("log destination is not a regular file: {}", path.display()),
));
}
if metadata.uid() != unsafe { libc::geteuid() } {
if process_uid != 0 && metadata.uid() != process_uid {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"log destination is owned by another user: {}",
path.display()
"log destination is owned by another user: {} (owner uid={}, expected uid={process_uid}); \
chmod does not change file ownership",
path.display(),
metadata.uid()
),
));
}
@@ -1081,6 +1110,121 @@ mod tests {
fs::remove_dir_all(&warning.log_dir).expect("temp dir should be removable");
}
#[test]
fn rolling_file_sink_reports_directory_creation_failure_with_context() {
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let blocker = dir.join("not-a-directory");
fs::write(&blocker, b"unchanged").expect("blocking file should exist");
let log_dir = blocker.join("logs");
let config = FileLoggingConfig::new(&log_dir, LogRotation::Daily, 7, 30);
let error = RollingFileSink::new("runtime-test", config)
.expect_err("a regular file must not be treated as a directory");
let message = error.to_string();
assert!(message.contains("failed to create log directory"));
assert!(message.contains(&log_dir.display().to_string()));
assert!(message.contains("ownership and write permissions"));
assert_eq!(fs::read(&blocker).expect("blocking file"), b"unchanged");
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[test]
fn rolling_log_file_reports_open_failure_with_context() {
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
let path = bucketed_log_path(&dir, "runtime-test", "missing");
let error = open_bucketed_log_file(&dir, "runtime-test", "missing")
.expect_err("a missing parent directory must prevent file creation");
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
let message = error.to_string();
assert!(message.contains("failed to open log file"));
assert!(message.contains(&path.display().to_string()));
assert!(message.contains("ownership and write permissions"));
#[cfg(unix)]
{
assert!(message.contains(&format!("process uid={}", unsafe { libc::geteuid() })));
assert!(message.contains(&format!("gid={}", unsafe { libc::getegid() })));
}
}
#[cfg(unix)]
#[test]
fn rolling_log_file_rejects_another_owner_even_with_world_writable_permissions() {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let path = bucketed_log_path(&dir, "runtime-test", "another-owner");
fs::write(&path, b"unchanged").expect("log file should exist");
fs::set_permissions(&path, fs::Permissions::from_mode(0o777))
.expect("log permissions should be writable by all users");
let file = fs::File::open(&path).expect("log should open");
let owner_uid = file.metadata().expect("log metadata").uid();
let process_uid = owner_uid.wrapping_add(1);
let error = super::validate_open_log_file_for_user(&file, &path, process_uid)
.expect_err("chmod must not bypass the ownership check");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
let message = error.to_string();
assert!(message.contains("log destination is owned by another user"));
assert!(message.contains(&format!("owner uid={owner_uid}")));
assert!(message.contains(&format!("expected uid={process_uid}")));
assert!(message.contains("chmod does not change file ownership"));
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
assert_eq!(
file.metadata().expect("log metadata").permissions().mode() & 0o777,
0o777
);
drop(file);
let reopened = open_bucketed_log_file(&dir, "runtime-test", "another-owner")
.expect("the actual owner should be able to reopen the log");
assert_eq!(
reopened
.metadata()
.expect("log metadata")
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
drop(reopened);
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[cfg(unix)]
#[test]
fn root_log_validation_accepts_existing_owners_but_rejects_hardlinks() {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let path = bucketed_log_path(&dir, "runtime-test", "existing-owner");
fs::write(&path, b"unchanged").expect("log file should exist");
let file = fs::File::open(&path).expect("log should open");
let owner_uid = file.metadata().expect("log metadata").uid();
super::validate_open_log_file_for_user(&file, &path, 0)
.expect("root may use a regular log file without changing its owner");
let metadata = file.metadata().expect("log metadata");
assert_eq!(metadata.uid(), owner_uid);
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
fs::hard_link(&path, dir.join("other-link.log")).expect("hardlink should exist");
let error = super::validate_open_log_file_for_user(&file, &path, 0)
.expect_err("root must still reject a log file with multiple hard links");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("multiple hard links"));
drop(file);
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[test]
fn pretty_formatter_omits_service_identity_fields() {
let writer = SharedBuffer::default();
@@ -0,0 +1,140 @@
#![cfg(target_os = "linux")]
use std::fs;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::path::PathBuf;
use std::process::Command;
use aether_runtime::{
init_reloadable_service_tracing, init_service_runtime, FileLoggingConfig, LogDestination,
LogFormat, LogRotation, ServiceRuntimeConfig,
};
#[test]
#[ignore = "requires the fixture image from tests/root_logging.Dockerfile and the production capability profile"]
fn root_appends_to_existing_logs_without_changing_ownership() {
if let Ok(scenario) = std::env::var("AETHER_TEST_ROOT_LOGGING_CASE") {
run_scenario(&scenario);
return;
}
for entrypoint in ["standard", "reloadable"] {
for destination in ["file", "both"] {
for format in ["pretty", "json"] {
for owner in ["0", "1000", "65532", "new"] {
let scenario = format!("{entrypoint}-{destination}-{format}-{owner}");
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--ignored",
"--exact",
"root_appends_to_existing_logs_without_changing_ownership",
"--nocapture",
])
.env("AETHER_TEST_ROOT_LOGGING_CASE", &scenario)
.env_remove("RUST_LOG")
.output()
.expect("root logging subprocess");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{scenario}: {stdout}\n{stderr}");
assert_eq!(
stdout.matches("root logging ready").count(),
usize::from(destination == "both"),
"{scenario}: {stdout}"
);
assert!(!stderr.contains("stdout logging"), "{scenario}: {stderr}");
}
}
}
}
}
fn run_scenario(scenario: &str) {
assert_eq!(unsafe { libc::geteuid() }, 0);
assert_eq!(unsafe { libc::getegid() }, 0);
let process_status = fs::read_to_string("/proc/self/status").expect("process status");
for capability in ["CapEff", "CapPrm", "CapBnd"] {
let value = process_status
.lines()
.find_map(|line| line.strip_prefix(&format!("{capability}:")))
.expect("capability field");
assert_eq!(
u64::from_str_radix(value.trim(), 16).expect("capability bits"),
0xa
);
}
assert!(process_status.lines().any(|line| {
line.strip_prefix("NoNewPrivs:")
.is_some_and(|value| value.trim() == "1")
}));
let parts: Vec<_> = scenario.split('-').collect();
let [entrypoint, destination, format, owner] = parts.as_slice() else {
panic!("invalid scenario: {scenario}");
};
let dir = PathBuf::from("/logs").join(scenario);
let bucket = chrono::Local::now().format("%Y-%m-%d");
let log_file = dir.join(format!("root-logging-test.{bucket}.log"));
let directory_metadata = fs::metadata(&dir).expect("pre-existing foreign-owned directory");
assert_eq!(directory_metadata.uid(), 1000);
assert_eq!(directory_metadata.gid(), 1000);
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
let expected_owner = if *owner == "new" {
assert!(!log_file.exists());
0
} else {
let owner_uid: u32 = owner.parse().expect("fixture owner");
let metadata = fs::metadata(&log_file).expect("pre-existing log");
assert_eq!(metadata.uid(), owner_uid);
assert_eq!(metadata.gid(), owner_uid);
assert_eq!(metadata.permissions().mode() & 0o777, 0o640);
owner_uid
};
let config = ServiceRuntimeConfig::new("root-logging-test", "info")
.with_log_destination(match *destination {
"file" => LogDestination::File,
"both" => LogDestination::Both,
other => panic!("unknown destination: {other}"),
})
.with_log_format(match *format {
"pretty" => LogFormat::Pretty,
"json" => LogFormat::Json,
other => panic!("unknown format: {other}"),
})
.with_file_logging(FileLoggingConfig::new(&dir, LogRotation::Daily, 7, 30));
let _reloader = match *entrypoint {
"standard" => {
init_service_runtime(config).expect("root should initialize file logging");
None
}
"reloadable" => Some(
init_reloadable_service_tracing("info", config)
.expect("root should initialize reloadable file logging"),
),
other => panic!("unknown entrypoint: {other}"),
};
tracing::info!("root logging ready");
let metadata = fs::metadata(&log_file).expect("written log file");
assert_eq!(metadata.uid(), expected_owner);
assert_eq!(metadata.gid(), expected_owner);
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
let contents = fs::read_to_string(&log_file).expect("log contents");
assert_eq!(contents.matches("root logging ready").count(), 1);
if *owner != "new" {
assert!(contents.starts_with("historical log\n"));
}
if *format == "json" {
let event: serde_json::Value =
serde_json::from_str(contents.lines().last().expect("log event"))
.expect("JSON file log");
assert_eq!(event["fields"]["message"], "root logging ready");
}
let directory_metadata = fs::metadata(&dir).expect("log directory");
assert_eq!(directory_metadata.uid(), 1000);
assert_eq!(directory_metadata.gid(), 1000);
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
}
+31 -74
View File
@@ -865,12 +865,12 @@ pub fn build_terminal_usage_context_seed(
request_type,
is_stream: plan.stream,
routing: build_runtime_routing_seed(plan, context),
request_headers: mask_sensitive_headers_in_json_value(context_value(
request_headers: validate_usage_headers_in_json_value(context_value(
context,
"original_headers",
)),
request_body: request_capture.request_body,
provider_request_headers: mask_sensitive_headers_in_json_value(
provider_request_headers: validate_usage_headers_in_json_value(
context_usage_value(context, "provider_request_headers")
.or_else(|| headers_to_json(&plan.headers)),
),
@@ -2497,7 +2497,7 @@ fn sanitize_usage_event_data(mut data: UsageEventData) -> UsageEventData {
}
fn sanitize_usage_header_capture(value: Option<Value>) -> Option<Value> {
mask_sensitive_headers_in_json_value(value).map(capture_usage_storage_value)
validate_usage_headers_in_json_value(value).map(capture_usage_storage_value)
}
fn trim_owned_non_empty_string(value: String) -> Option<String> {
@@ -2720,30 +2720,19 @@ fn headers_to_json(headers: &BTreeMap<String, String>) -> Option<Value> {
return None;
}
Some(Value::Object(Map::from_iter(headers.iter().map(
|(key, value)| (key.clone(), Value::String(mask_header_value(key, value))),
|(key, value)| (key.clone(), Value::String(value.clone())),
))))
}
const REDACTED_USAGE_VALUE: &str = "[redacted]";
fn is_sensitive_header(name: &str) -> bool {
aether_data_contracts::repository::usage::usage_header_value_is_sensitive(name)
}
fn mask_header_value(name: &str, value: &str) -> String {
if !is_sensitive_header(name) {
return value.to_string();
}
mask_sensitive_header_value(value)
}
fn mask_sensitive_header_value(_value: &str) -> String {
REDACTED_USAGE_VALUE.to_string()
}
/// Non-object values cannot be established as a valid header map and are
/// discarded instead of being persisted verbatim.
fn mask_sensitive_headers_in_json_value(value: Option<Value>) -> Option<Value> {
fn validate_usage_headers_in_json_value(value: Option<Value>) -> Option<Value> {
aether_data_contracts::repository::usage::sanitize_usage_headers_for_persistence(value)
}
@@ -3509,11 +3498,10 @@ mod tests {
build_terminal_usage_event_from_seed, build_usage_event_data_seed,
build_usage_event_data_seed_describing_request_bodies, decode_body_for_storage,
extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json,
mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value,
parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string,
LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed,
UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
REDACTED_USAGE_VALUE,
mask_sensitive_body_fields, parse_sse_body_for_storage, resolve_error_message,
trim_owned_non_empty_string, validate_usage_headers_in_json_value, LifecycleUsageSeed,
TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed,
UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
};
use crate::{
build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest,
@@ -5660,14 +5648,14 @@ mod tests {
assert_eq!(
event.data.response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"content-type": "application/json"
}))
);
assert_eq!(
event.data.client_response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"content-type": "application/json"
}))
);
@@ -6560,7 +6548,7 @@ mod tests {
}
#[test]
fn manual_terminal_seed_event_builder_sanitizes_headers_and_metadata_but_preserves_bodies() {
fn manual_terminal_seed_event_builder_sanitizes_metadata_but_preserves_headers_and_bodies() {
let event = build_terminal_usage_event_from_seed(TerminalUsageSeed {
terminal_state: UsageTerminalState::Completed,
client_contract: "openai:chat".to_string(),
@@ -6631,26 +6619,26 @@ mod tests {
assert_eq!(
event.data.request_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"accept": "application/json"
}))
);
assert_eq!(
event.data.provider_request_headers,
Some(json!({
"x-api-key": REDACTED_USAGE_VALUE
"x-api-key": "sk-proj-super-secret"
}))
);
assert_eq!(
event.data.response_headers,
Some(json!({
"set-cookie": REDACTED_USAGE_VALUE
"set-cookie": "session=extremely-secret-cookie"
}))
);
assert_eq!(
event.data.client_response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE
"authorization": "Bearer client-secret"
}))
);
assert_eq!(
@@ -6727,7 +6715,7 @@ mod tests {
}
#[test]
fn usage_event_data_seed_masks_headers_before_outcome_paths_use_it() {
fn usage_event_data_seed_preserves_headers_before_outcome_paths_use_it() {
let plan = ExecutionPlan {
request_id: "req-seed-sanitize-1".to_string(),
candidate_id: Some("cand-seed-sanitize-1".to_string()),
@@ -6766,7 +6754,7 @@ mod tests {
assert_eq!(
data.request_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer outcome-secret",
"accept": "application/json"
}))
);
@@ -6945,38 +6933,7 @@ mod tests {
}
#[test]
fn masks_known_sensitive_header_values() {
let token = "Bearer eyJhbGciOiJSUzI1NiJ9.payload-here.signature-tail";
let masked = mask_header_value("authorization", token);
assert_eq!(masked, REDACTED_USAGE_VALUE);
// 大小写不敏感
assert_eq!(
mask_header_value("Authorization", "12345678"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("X-Api-Key", "abcdefghij"),
REDACTED_USAGE_VALUE,
);
// Unknown custom headers are redacted by default.
assert_eq!(
mask_header_value("user-agent", "codex-tui/0.1"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("x-custom-auth", "tenant-secret"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("content-type", "application/json"),
"application/json",
);
}
#[test]
fn headers_to_json_masks_sensitive_headers_at_source() {
fn headers_to_json_preserves_original_headers_at_source() {
let mut headers = BTreeMap::new();
headers.insert(
"authorization".to_string(),
@@ -6995,17 +6952,17 @@ mod tests {
.get("authorization")
.and_then(|v| v.as_str())
.expect("authorization should be string");
assert_eq!(auth, REDACTED_USAGE_VALUE);
assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature");
let api_key = object
.get("x-api-key")
.and_then(|v| v.as_str())
.expect("x-api-key should be string");
assert_eq!(api_key, REDACTED_USAGE_VALUE);
assert_eq!(api_key, "sk-proj-1234567890abcdef");
assert_eq!(
object.get("user-agent").and_then(|v| v.as_str()),
Some(REDACTED_USAGE_VALUE),
Some("codex-tui/0.1"),
);
}
@@ -7015,27 +6972,27 @@ mod tests {
}
#[test]
fn mask_sensitive_headers_in_json_value_handles_object_form() {
fn validate_usage_headers_in_json_value_handles_object_form() {
let value = json!({
"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature",
"Cookie": "session=verylongcookievalue1234",
"Accept": "application/json",
});
let masked =
mask_sensitive_headers_in_json_value(Some(value)).expect("masked value should exist");
let object = masked.as_object().expect("expected object");
let captured =
validate_usage_headers_in_json_value(Some(value)).expect("captured value should exist");
let object = captured.as_object().expect("expected object");
let auth = object
.get("Authorization")
.and_then(|v| v.as_str())
.expect("Authorization should be string");
assert_eq!(auth, REDACTED_USAGE_VALUE);
assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature");
let cookie = object
.get("Cookie")
.and_then(|v| v.as_str())
.expect("Cookie should be string");
assert_eq!(cookie, REDACTED_USAGE_VALUE);
assert_eq!(cookie, "session=verylongcookievalue1234");
assert_eq!(
object.get("Accept").and_then(|v| v.as_str()),
@@ -7044,11 +7001,11 @@ mod tests {
}
#[test]
fn mask_sensitive_headers_discards_non_object() {
fn validate_usage_headers_discards_non_object() {
// None 输入返回 None
assert!(mask_sensitive_headers_in_json_value(None).is_none());
assert!(validate_usage_headers_in_json_value(None).is_none());
// 非 object 不是可验证的 header map,直接丢弃。
let masked = mask_sensitive_headers_in_json_value(Some(json!("not an object")));
let masked = validate_usage_headers_in_json_value(Some(json!("not an object")));
assert_eq!(masked, None);
}
+4 -1
View File
@@ -58,10 +58,13 @@ services:
app:
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
container_name: aether-app
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
user: "0:0"
read_only: true
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- FOWNER
security_opt:
- no-new-privileges:true
tmpfs:
+4 -1
View File
@@ -58,10 +58,13 @@ services:
app:
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
container_name: aether-app
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
user: "0:0"
read_only: true
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- FOWNER
security_opt:
- no-new-privileges:true
tmpfs:
@@ -0,0 +1,47 @@
# 历史权限空值升级修复
## 原因
旧版 API Key、用户、用户组及上游 Key 允许把 JSON 字面量 `null`、字符串 `"null"`(忽略大小写和首尾空白)以及空字符串作为未设置的权限。严格权限读取启用后,这些值会触发 `contains JSON null; use SQL NULL for an unset policy` 等错误,影响 API Key 鉴权、列表和用户、用户组读取。管理令牌的 IP 限制和权限也曾接受 JSON 字面量 `null`,但不接受字符串空值;严格校验同样会使这些旧令牌失效。
增量迁移 `20260908000000_normalize_legacy_policy_nulls.sql` 将这些已知的旧版空值转换成 SQL `NULL`,不修改既有迁移及其校验和。新安装和已有数据库升级均通过同一迁移机制执行。
## 覆盖范围
| 表 | 字段 |
| --- | --- |
| `api_keys` | `allowed_providers`、`allowed_api_formats`、`allowed_models`、`ip_rules` |
| `users` | `allowed_providers`、`allowed_api_formats`、`allowed_models` |
| `user_groups` | `allowed_providers`、`allowed_api_formats`、`allowed_models` |
| `provider_api_keys` | `api_formats`、`allowed_models` |
| `management_tokens` | `allowed_ips`、`permissions`(仅 JSON 字面量 `null`) |
共 5 张表、14 个字段,同时兼容 `json` 和 `jsonb` 列。迁移可重复执行,且只更新命中旧版空值的字段:
- 保留 SQL `NULL`、空数组 `[]`、正常名单、字符串化的名单和单字符串策略。
- 保留 `specific`、`deny_all`、`inherit` 等权限模式,不修改用户组成员关系。
- 管理令牌只转换 JSON 字面量 `null`,恢复旧版既有的未设置 IP 限制或 `legacy_full` 权限语义;字符串 `"null"`、空字符串、空数组和其他非法权限不转换,避免把原本无效的令牌权限变成旧版全权限。
- 不清理数组内部的 `null`、空白元素、数字、对象或其他异常权限;它们仍由严格读取逻辑拒绝,不能借迁移变成无限制访问。
- 不修改其他 JSON 字段,例如 `metadata` 内的 JSON `null`。
## 升级方式
先备份数据库,部署包含此迁移的新网关二进制或镜像,并保留原来的数据库连接配置与密钥。仅重启不包含此迁移的旧版本不会修复数据。
默认 `AETHER_GATEWAY_DATABASE_MODE=auto` 会在启动时执行挂起迁移,再进入正常服务。使用 `verify-only` 的部署需在相同数据库连接配置下先执行新版本的准备命令,再重启服务:
```sh
aether-gateway db prepare
```
迁移只更新上述权限列,不删除业务记录、不替换用户或 Key、不重建数据库。不要把空数组或任意异常 JSON 统一改成 SQL `NULL`,也不要通过关闭严格权限校验绕过问题。
升级后可以只读检查迁移记录:
```sql
SELECT version, description, success
FROM public._sqlx_migrations
WHERE version = 20260908000000;
```
该记录应存在且 `success = true`。若仍有权限解码错误,核对实际报错实例连接的数据库,以及是否有旧进程或外部工具继续写入旧格式;不要将其他类型的权限错误直接作为空值清除。
+65
View File
@@ -0,0 +1,65 @@
# 请求正文查看与性能边界
## 按需读取
请求详情的轻量读取仍使用 `GET /api/admin/usage/{id}?include_bodies=false`,返回正文可用性与记录概要,不解析正文。
查看正文时,管理界面使用 `GET /api/admin/usage/{id}?include_bodies=true&body_field={field}&body_format=raw`。`body_field` 仅允许以下值:
- `request_body`:客户端请求体。
- `provider_request_body`:提供商请求体。
- `response_body`:提供商响应体。
- `client_response_body`:客户端响应体。
网页正文读取不再经过服务器 JSON 解码链路。数据库中的 `payload_gzip` 原样返回,历史压缩列同样直传;历史内联 JSON 返回 JSON 字节。接口仍经过管理权限校验、正文捕获状态检查、引用归属校验及审计,不加载用户名称和提供商名称等无关详情。
二进制响应的 `X-Aether-Body-Encoding` 为 `gzip` 或 `json`,另含 `X-Aether-Usage-Id`、`X-Aether-Body-Field`,前端验证记录与字段匹配。使用 `application/octet-stream`、`Content-Encoding: identity`,避免 HTTP 中间件重复压缩或浏览器提前解压;`Cache-Control: no-store, no-transform` 避免缓存敏感正文及代理改写。跨域允许凭证时显式暴露上述协议头。错误通过 HTTP 状态及 `X-Aether-Body-Error` 返回,不要求主线程解析二进制错误响应。
未指定 `body_format=raw` 的服务端 JSON 接口保持原有行为,但网页不再调用它加载正文;前端详情 API 默认也只取概要。非法字段、空字段、raw 模式缺少字段或同时设置 `include_bodies=false` 返回 HTTP 400。
前端按请求和正文字段保留 Worker 句柄,最多缓存两份已解析正文,并按解压字节总量 64 MiB 预算淘汰最久未访问的 Worker。该预算不是浏览器进程内存上限:解析对象、字符串及加载中的数据仍有额外开销。切换正文来源或标签会取消未完成的下载与解压;关闭抽屉、切换记录或组件卸载会终止全部 Worker。迟到结果不显示、不入缓存。请求从进行中变为完成或失败时,正文缓存失效,并重新读取当前选中的正文。
## 渲染与资源控制
- 仅挂载当前标签的内容,不再后台渲染隐藏标签。
- JSON 与对话均连续虚拟滚动,不需要点击上一页或下一页。接近底部时自动读取后续内容,向上滚动可重新查看先前内容。
- 折叠节点不遍历其子节点;点击括号展开节点时完整展开该子树,无需逐层点击。JSON 内部按 50 个显示片段批量读取,视口与预读区域最多挂载 4 批(200 个片段)。离屏内容用高度占位,缓存仅保留视口附近最多 6 批,不随滚动积累 DOM 或正文副本。
- JSON 不再有“显示更多”或“继续显示”按钮:长字符串和长键名在 Worker 中分段转义,随滚动自动显示全部字符。纯文本及非 JSON 响应同样自动衔接完整内容。分段保持 Unicode 字符完整,续段不重复显示 JSON 行号。
- 虚拟显示范围不改变正文长度;复制按钮仍复制完整内容。JSON 顺序读取复用遍历游标,避免每次滚动都从正文起点重新遍历。
- 压缩数据以 transferable ArrayBuffer 交给 Worker;解压、UTF-8 解码、JSON 解析、JSON 遍历及对话解析均在 Worker 中进行,完整对象不返回页面主线程。
- 对话内部按每批最多 10 个顶层块预览并自动衔接,限制嵌套块和文本传输量,长内容明确提示并支持增加预览。完整复制在用户点击后由 Worker 生成,不受虚拟显示范围影响。
- Worker 解压时逐块检查 64 MiB 上限,损坏 gzip、无效 UTF-8 和无效 JSON 给出明确错误。后台任务 30 秒超时会终止 Worker;不支持 Worker 或原生 gzip 解压的浏览器提示升级,不回退到主线程解析。
- 服务端最多 4 个后台解码任务的保护仍用于复制 cURL、请求重放等实际内部调用,不是网页解压的兼容回退。存储读取实现复用,不维护两套数据库读取逻辑。
- 数据库读取先按存储字节过滤:压缩正文最大 65 MiB,未压缩正文最大 64 MiB,超限不将完整载荷读入网关;浏览器还会独立校验解压后的大小。
## 错误定位
二进制接口的 `X-Aether-Body-Error` 和 JSON 接口的 `body_load_error_codes` 使用以下存储错误码;浏览器解压失败也映射为相同的明确提示:
| 错误码 | 含义 |
| --- | --- |
| `too_large` | 解压后正文超过 64 MiB 的安全读取上限。 |
| `decode_failed` | 正文解压或 JSON 解析失败。 |
| `missing` | 记录标记正文可用,但未能解析到实际存储内容。 |
| `storage_unavailable` | 其他存储读取错误;内部连接信息不会返回给页面。 |
前端分别显示请求超时、网络失败、HTTP 错误及上述存储错误。`too_large` 和 `decode_failed` 不提供无意义的重复重试;其他错误可手动重试。正文请求仍沿用现有 API 超时配置。
完整记录模式与已有记录不做静默截断;64 MiB 解压安全上限没有放宽。因此,超出上限的历史正文仍不能在线预览,但页面会明确说明限制,不再仅显示笼统的加载失败。
前后端需要一同更新。前端收到缺少二进制协议头、记录不匹配或字段不匹配的响应时会拒绝显示,并提示检查前后端版本;不会自动退回耗资源的完整 JSON 正文接口。构建前端时必须同时发布生成的 Worker JavaScript 资源。
## 针对性验证
```sh
cd frontend
npm run test:run -- src/features/usage src/api/__tests__/dashboard-body-loading.spec.ts
npm run type-check
```
```sh
cargo test -p aether-data-postgres usage_body_decode --lib --offline
cargo test -p aether-gateway admin_usage_detail --lib --offline
cargo test -p aether-gateway raw_body --lib --offline
cargo test -p aether-gateway body_load_errors_expose_safe_codes --lib --offline
```
+17
View File
@@ -0,0 +1,17 @@
# Usage header capture
Usage HTTP captures preserve original header values for client requests,
provider requests, provider responses, and client responses. Header maps are
validated as JSON objects but their values are not redacted. Capture settings
and existing access controls still apply.
These records can contain credentials such as Authorization, API keys, and
cookies, as well as session identifiers and client metadata. Restrict access
to usage details, database records, exports, and backups accordingly.
Previously stored `[redacted]` values cannot be recovered. Original values
are available only for new captures after deploying this change.
The request detail drawer reads these values directly from the administrator
usage-detail API, including when bodies are not loaded. No frontend setting
can recover header values that were already replaced during capture.
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: getMock } }))
import { dashboardApi } from '@/api/dashboard'
import { cache } from '@/utils/cache'
beforeEach(() => {
cache.clear()
getMock.mockReset()
getMock.mockResolvedValue({ data: { id: 'usage-1' } })
})
describe('dashboard body loading', () => {
it('requests opaque body bytes, outside the JSON detail cache', async () => {
const bytes = new ArrayBuffer(20)
const controller = new AbortController()
getMock.mockResolvedValue({ data: bytes, headers: { 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'request_body' } })
await expect(dashboardApi.getRequestBody('usage-1', 'request_body', controller.signal)).resolves.toEqual({ bytes, encoding: 'gzip' })
expect(getMock).toHaveBeenCalledWith('/api/admin/usage/usage-1', { params: { include_bodies: true, body_field: 'request_body', body_format: 'raw' }, responseType: 'arraybuffer', signal: controller.signal })
})
it.each([
{ 'x-aether-body-encoding': 'br', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'request_body' },
{ 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-other', 'x-aether-body-field': 'request_body' },
{ 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'response_body' },
{},
])('rejects mismatched or invalid body response headers', async headers => {
getMock.mockResolvedValue({ data: new ArrayBuffer(10), headers })
await expect(dashboardApi.getRequestBody('usage-1', 'request_body')).rejects.toThrow('Invalid body response')
})
it('defaults to shallow details and isolates cached records', async () => {
await dashboardApi.getRequestDetail('usage-1')
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-1', { params: { include_bodies: false } })
await dashboardApi.getRequestDetail('usage-1', { cacheTtlMs: 5000 })
await dashboardApi.getRequestDetail('usage-2', { cacheTtlMs: 5000 })
await dashboardApi.getRequestDetail('usage-1', { cacheTtlMs: 5000 })
expect(getMock).toHaveBeenCalledTimes(3)
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-2', { params: { include_bodies: false } })
})
it('does not share abortable requests with another caller or a cancelled request', async () => {
const first = new AbortController()
const second = new AbortController()
const firstRequest = dashboardApi.getRequestDetail('usage-1', { signal: first.signal })
first.abort()
const secondRequest = dashboardApi.getRequestDetail('usage-1', { signal: second.signal })
await Promise.all([firstRequest, secondRequest])
expect(getMock).toHaveBeenCalledTimes(2)
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-1', {
params: { include_bodies: false }, signal: second.signal,
})
})
})
+34 -12
View File
@@ -190,6 +190,16 @@ export interface RequestSettlementSnapshot {
[key: string]: unknown
}
export type RequestBodyField = 'request_body' | 'provider_request_body' | 'response_body' | 'client_response_body'
export type RequestBodyLoadErrorCode = 'too_large' | 'decode_failed' | 'missing' | 'storage_unavailable'
export class RequestBodyProtocolError extends Error {
constructor() {
super('Invalid body response')
this.name = 'RequestBodyProtocolError'
}
}
export interface RequestDetail {
id: string // UUID
request_id: string
@@ -296,6 +306,7 @@ export interface RequestDetail {
response_body?: boolean
client_response_body?: boolean
} | null
body_load_error_codes?: Partial<Record<RequestBodyField, RequestBodyLoadErrorCode>> | null
metadata?: Record<string, unknown>
routing?: Record<string, unknown>
body_capture?: Record<string, unknown>
@@ -455,21 +466,32 @@ export const dashboardApi = {
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(
requestId: string,
options: { includeBodies?: boolean, cacheTtlMs?: number } = {}
options: { includeBodies?: boolean, cacheTtlMs?: number, signal?: AbortSignal } = {}
): Promise<RequestDetail> {
const includeBodies = options.includeBodies ?? true
const includeBodies = options.includeBodies ?? false
const cacheTtlMs = options.cacheTtlMs ?? 0
const cacheKey = buildCacheKey('dashboard:request-detail', { requestId, includeBodies })
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: includeBodies },
})
return response.data
},
cacheTtlMs
)
const fetchDetail = async () => {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: includeBodies },
...(options.signal ? { signal: options.signal } : {}),
})
return response.data
}
return options.signal ? fetchDetail() : cachedRequest(cacheKey, fetchDetail, cacheTtlMs)
},
async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal) {
const response = await apiClient.get<ArrayBuffer>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: true, body_field: field, body_format: 'raw' },
responseType: 'arraybuffer',
signal,
})
const encoding = response.headers['x-aether-body-encoding']
if ((encoding !== 'gzip' && encoding !== 'json') || response.headers['x-aether-usage-id'] !== requestId || response.headers['x-aether-body-field'] !== field) {
throw new RequestBodyProtocolError()
}
return { bytes: response.data, encoding: encoding as 'gzip' | 'json' }
},
async prefetchRequestDetail(requestId: string): Promise<void> {
@@ -1,11 +1,11 @@
<template>
<Card
variant="interactive"
class="flex min-w-0 flex-col cursor-pointer overflow-hidden"
class="flex max-h-96 w-full min-w-0 flex-col cursor-pointer overflow-hidden"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<div class="flex items-start gap-2 p-4 pb-3">
<div class="flex shrink-0 items-start gap-2 p-4 pb-3">
<slot name="drag-handle" />
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-base font-semibold"
@@ -88,7 +88,7 @@
</Badge>
</div>
<div class="flex flex-1 flex-col gap-4 px-4 pb-4">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain break-words px-4 pb-4">
<div class="space-y-2 rounded-xl border border-border/40 bg-muted/20 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ legacyT('余额监控') }}</span>
@@ -176,7 +176,7 @@
</div>
<div
class="flex items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
class="flex shrink-0 items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
@click.stop
>
<Button
@@ -638,7 +638,7 @@
:copied="Boolean(copiedStates[activeTab])"
:custom-copy="true"
:expand-disabled="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')"
:copy-disabled="viewMode === 'compare'"
:copy-disabled="viewMode === 'compare' || bodyCopying || (supportsConversationView && !hasValidConversation)"
max-height="500px"
@update:expand-depth="currentExpandDepth = $event"
@copy="copyContent(activeTab)"
@@ -723,7 +723,10 @@
<!-- 区域3:常驻按钮(展开/收缩、复制) -->
<div class="w-px h-3.5 bg-border mx-0.5" />
</template>
<TabsContent value="request-headers">
<TabsContent
v-if="activeTab === 'request-headers'"
value="request-headers"
>
<RequestHeadersContent
:detail="detail"
:view-mode="viewMode"
@@ -736,7 +739,10 @@
/>
</TabsContent>
<TabsContent value="request-body">
<TabsContent
v-if="activeTab === 'request-body'"
value="request-body"
>
<div
v-if="isRequestBodyLoading"
class="p-4"
@@ -752,6 +758,7 @@
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
v-if="canRetryBodyContentLoad"
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@@ -761,22 +768,30 @@
重试
</Button>
</div>
<ConversationView
<BodyConversationContent
v-else-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
:body-document="currentRequestBody"
kind="request"
:api-format="currentRequestBodyApiFormat"
empty-message="无请求体信息"
@load-error="handleBodyDocumentError"
/>
<JsonContent
v-else
:data="currentRequestBody"
:data="null"
:body-document="currentRequestBody"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无请求体信息"
@load-error="handleBodyDocumentError"
/>
</TabsContent>
<TabsContent value="response-headers">
<TabsContent
v-if="activeTab === 'response-headers'"
value="response-headers"
>
<RequestHeadersContent
v-if="viewMode === 'compare'"
:detail="detail"
@@ -803,7 +818,10 @@
/>
</TabsContent>
<TabsContent value="response-body">
<TabsContent
v-if="activeTab === 'response-body'"
value="response-body"
>
<div
v-if="isResponseBodyLoading"
class="p-4"
@@ -819,6 +837,7 @@
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
v-if="canRetryBodyContentLoad"
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@@ -828,22 +847,30 @@
重试
</Button>
</div>
<ConversationView
<BodyConversationContent
v-else-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
:body-document="currentResponseBody"
kind="response"
:api-format="currentResponseBodyApiFormat"
empty-message="无响应体信息"
@load-error="handleBodyDocumentError"
/>
<JsonContent
v-else
:data="currentResponseBody"
:data="null"
:body-document="currentResponseBody"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应体信息"
@load-error="handleBodyDocumentError"
/>
</TabsContent>
<TabsContent value="metadata">
<TabsContent
v-if="activeTab === 'metadata'"
value="metadata"
>
<JsonContent
:data="metadataPanelData"
:view-mode="viewMode"
@@ -874,7 +901,8 @@
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, shallowRef, watch, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import axios from 'axios'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
@@ -886,7 +914,8 @@ import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { dashboardApi, type RequestBodyField, type RequestDetail } from '@/api/dashboard'
import { formatRequestBodyLoadError, formatStoredBodyLoadError } from '../utils/body-load-error'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import type { UsageRecord } from '../types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -928,7 +957,10 @@ import {
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
import JsonContentPanel from './JsonContentPanel.vue'
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
import BodyConversationContent from './RequestDetailDrawer/BodyConversationContent.vue'
import { BodyDocument } from '../utils/body-document'
import { BodyDocumentError, MAX_BODY_BYTES } from '../utils/body-document-protocol'
import { useToast } from '@/composables/useToast'
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
import ReplayDialog from './ReplayDialog.vue'
import ServiceTierFacts from './ServiceTierFacts.vue'
@@ -939,14 +971,6 @@ import {
resolveServiceTierFacts,
} from '../utils/service-tier'
// 对话解析器
import {
renderRequest,
renderResponse,
type RenderResult,
type RenderBlock,
} from '../conversation'
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const props = defineProps<{
@@ -1008,7 +1032,7 @@ const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
const detail = shallowRef<RequestDetail | null>(null)
const detailUsageAvailable = computed(() => detail.value?.usage_available !== false)
const detailPricingAvailable = computed(() => (
detailUsageAvailable.value && detail.value?.usage_pricing_available !== false
@@ -1026,6 +1050,7 @@ const currentExpandDepth = ref(0)
const dataSource = ref<'client' | 'provider'>('provider')
const contentViewMode = ref<'json' | 'conversation'>('json')
const { copyToClipboard } = useClipboard()
const { error: showBodyError } = useToast()
const historicalPricing = ref<{
input_price: string
output_price: string
@@ -1405,7 +1430,10 @@ const curlCopied = ref(false)
const replayDialogOpen = ref(false)
const bodyLoading = ref(false)
const bodyLoadError = ref<string | null>(null)
const bodiesLoadedForRequestId = ref<string | null>(null)
const bodyDocuments = shallowRef(new Map<RequestBodyField, BodyDocument>())
const bodyCopying = ref(false)
const bodyLoadFailures = new Map<RequestBodyField, string>()
let bodyLoadController: AbortController | null = null
const showTimeline = ref(false)
const AUTO_REFRESH_INTERVAL_MS = 1000
const TIMELINE_MOUNT_DELAY_MS = 120
@@ -1457,10 +1485,6 @@ watch(activeTab, (newTab) => {
contentViewMode.value = 'json'
}
dataSource.value = getDefaultDataSourceForTab(newTab)
if (['request-body', 'response-body'].includes(newTab)) {
void ensureBodyContentLoaded()
}
})
const { isDark } = useDarkMode()
@@ -1666,6 +1690,33 @@ const hasResponseBodyAvailable = computed(() => {
|| hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)
})
const requestBodyField = computed<RequestBodyField>(() => {
if (dataSource.value === 'provider' && hasBodyContent(detail.value?.has_provider_request_body, detail.value?.provider_request_body)) {
return 'provider_request_body'
}
return hasBodyContent(detail.value?.has_request_body, detail.value?.request_body)
? 'request_body' : 'provider_request_body'
})
const responseBodyField = computed<RequestBodyField>(() => {
if (dataSource.value === 'client' && hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)) {
return 'client_response_body'
}
return hasBodyContent(detail.value?.has_response_body, detail.value?.response_body)
? 'response_body' : 'client_response_body'
})
const activeBodyField = computed(() => {
if (activeTab.value === 'request-body') return requestBodyField.value
if (activeTab.value === 'response-body') return responseBodyField.value
return null
})
watch([activeBodyField, () => props.isOpen, () => detail.value?.id], () => {
cancelBodyContentLoad()
void ensureBodyContentLoaded()
})
const isRequestBodyLoading = computed(() => {
return bodyLoading.value && activeTab.value === 'request-body' && !currentRequestBody.value
})
@@ -1676,16 +1727,23 @@ const isResponseBodyLoading = computed(() => {
const requestBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.request_body || errors?.provider_request_body)
return Boolean(errors?.[requestBodyField.value])
})
const responseBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.response_body || errors?.client_response_body)
return Boolean(errors?.[responseBodyField.value])
})
const bodyLoadErrorMessage = computed(() => {
return bodyLoadError.value || '正文内容加载失败,请重试'
return bodyLoadError.value || formatStoredBodyLoadError(
activeBodyField.value ? detail.value?.body_load_error_codes?.[activeBodyField.value] : undefined,
)
})
const canRetryBodyContentLoad = computed(() => {
const code = activeBodyField.value ? detail.value?.body_load_error_codes?.[activeBodyField.value] : undefined
return code !== 'too_large' && code !== 'decode_failed'
})
const requestBodyLoadErrorVisible = computed(() => {
@@ -1802,20 +1860,12 @@ function setDataSource(source: 'client' | 'provider') {
// 获取当前数据源的请求体数据
const currentRequestBody = computed(() => {
if (!detail.value) return null
if (dataSource.value === 'provider' && detail.value.provider_request_body) {
return detail.value.provider_request_body
}
return detail.value.request_body
return bodyDocuments.value.get(requestBodyField.value) ?? null
})
// 获取当前数据源的响应体数据
const currentResponseBody = computed(() => {
if (!detail.value) return null
if (dataSource.value === 'client' && detail.value.client_response_body) {
return detail.value.client_response_body
}
return detail.value.response_body
return bodyDocuments.value.get(responseBodyField.value) ?? null
})
const currentRequestBodyApiFormat = computed(() => {
@@ -1855,11 +1905,11 @@ const activeJsonPanelData = computed(() => {
case 'request-headers':
return currentHeaderData.value
case 'request-body':
return currentRequestBody.value
return null
case 'response-headers':
return currentResponseHeaderData.value
case 'response-body':
return currentResponseBody.value
return null
case 'metadata':
return metadataPanelData.value
default:
@@ -1873,30 +1923,6 @@ const activeJsonPanelTitle = computed(() => {
return 'JSON'
})
// 请求体渲染结果
const requestRenderResult = computed<RenderResult>(() => {
const body = currentRequestBody.value
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'request-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderRequest(body, currentResponseBody.value, currentRequestBodyApiFormat.value)
})
// 响应体渲染结果
const responseRenderResult = computed<RenderResult>(() => {
const body = currentResponseBody.value
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'response-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderResponse(body, currentRequestBody.value, currentResponseBodyApiFormat.value)
})
// 当前 Tab 是否支持对话视图
const supportsConversationView = computed(() => {
return ['request-body', 'response-body'].includes(activeTab.value)
@@ -2376,15 +2402,6 @@ function hasContent(data: unknown): boolean {
return true
}
function hasBodyLoadErrors(errors: RequestDetail['body_load_errors'] | null | undefined): boolean {
return Boolean(
errors?.request_body ||
errors?.provider_request_body ||
errors?.response_body ||
errors?.client_response_body
)
}
function toFiniteNumber(value: unknown): number | null {
const num = Number(value)
return Number.isFinite(num) ? num : null
@@ -2581,9 +2598,11 @@ watch([() => props.isOpen, () => props.requestId], async ([isOpen, requestId]) =
stopAutoRefresh()
showTimeline.value = false
clearTimelineMountTimer()
bodyLoading.value = false
bodyLoadError.value = null
bodiesLoadedForRequestId.value = null
resetBodyContentState()
detail.value = null
activeTab.value = 'request-headers'
++loadDetailRequestId
loadDetailInFlight = false
}
})
@@ -2596,56 +2615,99 @@ function detailMatchesRequestId(
}
async function ensureBodyContentLoaded() {
if (!props.requestId || !detail.value) return
const cacheKey = detail.value.request_id || detail.value.id
if (bodiesLoadedForRequestId.value === cacheKey || bodyLoading.value) return
if (!hasRequestBodyAvailable.value && !hasResponseBodyAvailable.value) return
const field = activeBodyField.value
const usageId = props.requestId
if (!props.isOpen || !usageId || !field || !detailMatchesRequestId(detail.value, usageId)) return
if (!detail.value || bodyLoading.value) return
const cached = bodyDocuments.value.get(field)
if (cached) {
const documents = new Map(bodyDocuments.value)
documents.delete(field)
documents.set(field, cached)
bodyDocuments.value = documents
return
}
if (!hasBodyContent(detail.value[`has_${field}`], detail.value[field])) return
const previousFailure = bodyLoadFailures.get(field)
if (previousFailure) {
bodyLoadError.value = previousFailure
return
}
const requestId = ++bodyLoadRequestId
const controller = new AbortController()
bodyLoadController = controller
bodyLoading.value = true
bodyLoadError.value = null
try {
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
if (requestId !== bodyLoadRequestId || !detail.value) return
detail.value = {
...detail.value,
request_body: response.request_body,
provider_request_body: response.provider_request_body,
response_body: response.response_body,
client_response_body: response.client_response_body,
has_request_body: response.has_request_body,
has_provider_request_body: response.has_provider_request_body,
has_response_body: response.has_response_body,
has_client_response_body: response.has_client_response_body,
body_load_errors: response.body_load_errors,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
const response = await dashboardApi.getRequestBody(detail.value.id, field, controller.signal)
const document = await BodyDocument.load(response.bytes, response.encoding, controller.signal)
if (requestId !== bodyLoadRequestId || controller.signal.aborted || !detail.value ||
!props.isOpen || !detailMatchesRequestId(detail.value, usageId)) {
document.dispose()
return
}
if (!hasBodyLoadErrors(response.body_load_errors)) {
bodiesLoadedForRequestId.value = cacheKey
const documents = new Map(bodyDocuments.value)
let cachedBytes = document.byteLength
for (const existing of documents.values()) cachedBytes += existing.byteLength
for (const [cachedField, existing] of documents) {
if (documents.size < 2 && cachedBytes <= MAX_BODY_BYTES) break
existing.dispose()
documents.delete(cachedField)
cachedBytes -= existing.byteLength
}
documents.set(field, document)
bodyDocuments.value = documents
detail.value = { ...detail.value, body_load_errors: { ...detail.value.body_load_errors, [field]: false }, body_load_error_codes: { ...detail.value.body_load_error_codes, [field]: undefined } }
} catch (err) {
if (requestId !== bodyLoadRequestId) return
if (requestId !== bodyLoadRequestId || controller.signal.aborted || axios.isCancel(err)) return
log.error('Failed to load request bodies:', err)
bodyLoadError.value = '正文内容加载失败,请重试'
bodyLoadError.value = formatRequestBodyLoadError(err)
bodyLoadFailures.set(field, bodyLoadError.value)
const code = err instanceof BodyDocumentError ? err.code : axios.isAxiosError(err) ? err.response?.headers?.['x-aether-body-error'] : undefined
if ((code === 'too_large' || code === 'decode_failed') && detail.value) {
detail.value = { ...detail.value, body_load_error_codes: { ...detail.value.body_load_error_codes, [field]: code } }
}
} finally {
if (requestId === bodyLoadRequestId) {
bodyLoading.value = false
bodyLoadController = null
}
}
}
function handleBodyDocumentError(error: unknown) {
const field = activeBodyField.value
if (!field) return
const documents = new Map(bodyDocuments.value)
documents.get(field)?.dispose()
documents.delete(field)
bodyDocuments.value = documents
bodyLoadError.value = formatRequestBodyLoadError(error)
bodyLoadFailures.set(field, bodyLoadError.value)
}
function retryBodyContentLoad() {
if (activeBodyField.value) bodyLoadFailures.delete(activeBodyField.value)
bodyLoadError.value = null
void ensureBodyContentLoaded()
}
function cancelBodyContentLoad() {
++bodyLoadRequestId
bodyLoadController?.abort()
bodyLoadController = null
bodyLoading.value = false
bodyLoadError.value = null
}
function resetBodyContentState() {
cancelBodyContentLoad()
for (const document of bodyDocuments.value.values()) document.dispose()
bodyDocuments.value = new Map()
bodyLoadFailures.clear()
}
async function loadDetail(id: string, silent = false) {
if (silent && loadDetailInFlight) {
return
@@ -2662,9 +2724,7 @@ async function loadDetail(id: string, silent = false) {
timelineHasTrace.value = false
showTimeline.value = false
clearTimelineMountTimer()
++bodyLoadRequestId
bodyLoading.value = false
bodyLoadError.value = null
resetBodyContentState()
}
error.value = null
try {
@@ -2678,13 +2738,18 @@ async function loadDetail(id: string, silent = false) {
const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey
const bodySnapshotChanged = sameRequest && previousDetail != null &&
['pending', 'streaming'].includes(previousDetail.status ?? '') && previousDetail.status !== response.status
const preserveBodies = sameRequest && !bodySnapshotChanged
const nextDetail: RequestDetail = {
...response,
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
request_body: preserveBodies ? previousDetail?.request_body : undefined,
provider_request_body: preserveBodies ? previousDetail?.provider_request_body : undefined,
response_body: preserveBodies ? previousDetail?.response_body : undefined,
client_response_body: preserveBodies ? previousDetail?.client_response_body : undefined,
body_load_errors: preserveBodies ? previousDetail?.body_load_errors : response.body_load_errors,
body_load_error_codes: preserveBodies ? previousDetail?.body_load_error_codes : response.body_load_error_codes,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
@@ -2695,7 +2760,7 @@ async function loadDetail(id: string, silent = false) {
}
detailModelRevision.value = ++modelSnapshotRevision
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
if (!sameRequest || bodySnapshotChanged) resetBodyContentState()
emitDetailRequestState(nextDetail)
// 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载
@@ -2718,7 +2783,8 @@ async function loadDetail(id: string, silent = false) {
}
// 根据当前 Tab 的数据可用性自动选择默认数据源
dataSource.value = getDefaultDataSourceForTab(activeTab.value)
if (!silent || !sameRequest) dataSource.value = getDefaultDataSourceForTab(activeTab.value)
if (bodySnapshotChanged) void nextTick().then(ensureBodyContentLoaded)
// 使用请求记录中保存的历史价格
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
@@ -2844,6 +2910,7 @@ onBeforeUnmount(() => {
clearTimelineMountTimer()
loadDetailRequestId += 1
loadDetailInFlight = false
resetBodyContentState()
})
function formatDateTime(dateStr: string | null | undefined): string {
@@ -2936,108 +3003,37 @@ function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers:
return `> ${formatNumber(start)} tokens`
}
/** 将 RenderResult 格式化为可复制的文本 */
function formatRenderResultAsText(result: RenderResult): string {
if (result.error) {
return `[Error] ${result.error}`
}
const parts: string[] = []
for (const block of result.blocks) {
const text = formatBlockAsText(block)
if (text) {
parts.push(text)
async function copyContent(tabName: string) {
if (!detail.value || viewMode.value === 'compare' || bodyCopying.value) return
const usageId = detail.value.id
const document = tabName === 'request-body' ? currentRequestBody.value
: tabName === 'response-body' ? currentResponseBody.value : null
bodyCopying.value = true
try {
let text: string
if (document) {
text = await document.copy(contentViewMode.value === 'conversation' ? {
kind: tabName === 'request-body' ? 'request' : 'response',
apiFormat: tabName === 'request-body' ? currentRequestBodyApiFormat.value : currentResponseBodyApiFormat.value,
} : undefined)
} else {
const data = tabName === 'request-headers' ? (dataSource.value === 'provider' ? detail.value.provider_request_headers : detail.value.request_headers)
: tabName === 'response-headers' ? currentResponseHeaderData.value
: tabName === 'metadata' ? metadataPanelData.value : null
if (data == null) return
text = JSON.stringify(data, null, 2)
}
}
return parts.join('\n\n---\n\n')
}
/** 将单个 RenderBlock 格式化为文本 */
function formatBlockAsText(block: RenderBlock): string {
switch (block.type) {
case 'text':
return block.content
case 'code':
return block.language
? `\`\`\`${block.language}\n${block.code}\n\`\`\``
: `\`\`\`\n${block.code}\n\`\`\``
case 'collapsible':
return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
case 'error':
return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
case 'image':
return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
case 'tool_use':
return `[Tool: ${block.toolName}]\n${block.input}`
case 'tool_result':
return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
case 'message': {
const roleLabel = block.roleLabel || block.role
const contentText = block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')
return `[${roleLabel}]\n${contentText}`
if (!props.isOpen || detail.value?.id !== usageId) return
if (await copyToClipboard(text, false)) {
copiedStates.value[tabName] = true
setTimeout(() => { copiedStates.value[tabName] = false }, 2000)
} else {
showBodyError('复制失败,请检查剪贴板权限后重试。')
}
case 'container':
return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
case 'label':
return `${block.label}: ${block.value}`
case 'divider':
return '---'
case 'badge':
return ''
default:
return ''
}
}
// 复制内容(支持 JSON 和对话两种模式)
function copyContent(tabName: string) {
if (!detail.value) return
if (viewMode.value === 'compare') return
let textToCopy = ''
// 对话视图模式:复制格式化的对话文本
if (contentViewMode.value === 'conversation') {
if (tabName === 'request-body') {
textToCopy = formatRenderResultAsText(requestRenderResult.value)
} else if (tabName === 'response-body') {
textToCopy = formatRenderResultAsText(responseRenderResult.value)
}
} else {
// JSON 视图模式:复制原始 JSON
let data: unknown = null
switch (tabName) {
case 'request-headers':
data = dataSource.value === 'provider'
? detail.value.provider_request_headers
: detail.value.request_headers
break
case 'request-body':
data = currentRequestBody.value
break
case 'response-headers':
data = currentResponseHeaderData.value
break
case 'response-body':
data = currentResponseBody.value
break
case 'metadata':
data = metadataPanelData.value
break
}
if (data) {
textToCopy = JSON.stringify(data, null, 2)
}
}
if (textToCopy) {
copyToClipboard(textToCopy, false)
copiedStates.value[tabName] = true
setTimeout(() => {
copiedStates.value[tabName] = false
}, 2000)
} catch (error) {
if (props.isOpen && detail.value?.id === usageId) showBodyError(formatRequestBodyLoadError(error))
} finally {
bodyCopying.value = false
}
}
@@ -0,0 +1,75 @@
<template>
<div>
<div
v-if="!bodyDocument"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<VirtualBodyContent
v-else
:key="viewRevision"
ref="viewer"
:load-chunk="loadChunk"
:estimated-height="1000"
@load-error="emit('load-error', $event)"
>
<template #default="{ chunk, index }">
<ConversationView
:render-result="chunk.result"
:empty-message="emptyMessage"
embedded
/>
<div
v-if="chunk.truncated"
class="px-3 py-2 text-xs text-muted-foreground"
>
长内容仅显示预览,复制仍保留完整内容。
<button
v-if="(previewLimits[index] ?? 64_000) < 1_024_000"
type="button"
class="text-primary hover:underline"
@click="showMore(index)"
>
显示更多
</button>
</div>
</template>
</VirtualBodyContent>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ConversationView from './ConversationView.vue'
import VirtualBodyContent from './VirtualBodyContent.vue'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyConversationPage } from '../../utils/body-document-protocol'
const props = defineProps<{
bodyDocument: BodyDocument | null
kind: 'request' | 'response'
apiFormat?: string
emptyMessage: string
}>()
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewer = ref<{ refresh: (index: number) => void } | null>(null)
const viewRevision = ref(0)
const previewLimits = ref<Record<number, number>>({})
function loadChunk(index: number): Promise<BodyConversationPage> {
const document = props.bodyDocument
if (!document) return Promise.resolve({ result: { blocks: [] }, hasNext: false, truncated: false })
return document.conversation({ kind: props.kind, apiFormat: props.apiFormat, page: index, previewLimit: previewLimits.value[index] ?? 64_000 })
}
function showMore(index: number) {
previewLimits.value = { ...previewLimits.value, [index]: (previewLimits.value[index] ?? 64_000) + 64_000 }
viewer.value?.refresh(index)
}
watch([() => props.bodyDocument, () => props.kind, () => props.apiFormat], () => {
viewRevision.value += 1
previewLimits.value = {}
})
</script>
@@ -1,5 +1,5 @@
<template>
<div class="max-h-[500px] overflow-y-auto">
<div :class="{ 'max-h-[500px] overflow-y-auto': !embedded }">
<div class="flex flex-col gap-2 p-2">
<!-- 渲染错误提示 -->
<div
@@ -18,6 +18,17 @@
{{ emptyMessage }}
</div>
<template v-else-if="embedded">
<BlockRenderer :blocks="renderResult.blocks" />
<div
v-if="renderResult.isStream"
class="flex items-center gap-1.5 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-lg w-fit"
>
<Zap class="w-4 h-4" />
<span>流式响应</span>
</div>
</template>
<!-- 轮次视图 -->
<template v-else>
<!-- System Prompt -->
@@ -110,12 +121,14 @@
import { ref, computed, watch, nextTick } from 'vue'
import { AlertCircle, Zap, Settings, ChevronRight, ChevronDown } from 'lucide-vue-next'
import TurnCard from './TurnCard.vue'
import BlockRenderer from './BlockRenderer.vue'
import type { RenderResult } from '../../conversation'
import { groupRenderBlocksIntoTurns } from '../../conversation/grouper'
const props = defineProps<{
renderResult: RenderResult
emptyMessage: string
embedded?: boolean
}>()
// 状态
@@ -1,406 +1,184 @@
<template>
<div>
<div
v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)"
v-if="!bodyDocument && (data == null || (typeof data === 'object' && (Array.isArray(data) ? data.length === 0 : Object.keys(data).length === 0)))"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<!-- 纯字符串数据(非 JSON 对象) -->
<Card
v-else-if="typeof data === 'string'"
class="bg-muted/30 overflow-hidden"
>
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
</div>
</Card>
<!-- 非 JSON 响应(如 HTML 错误页面) -->
<Card
v-else-if="hasParseError"
class="bg-muted/30 overflow-hidden"
>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
<div class="flex items-start gap-2">
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
<span class="text-xs text-amber-700 dark:text-amber-300">{{ parseErrorMessage }}</span>
</div>
</div>
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ rawResponseContent }}</pre>
</div>
</Card>
<Card
v-else
class="bg-muted/30 overflow-hidden"
>
<!-- JSON 查看器 -->
<div
<VirtualBodyContent
:key="viewRevision"
ref="viewer"
class="json-viewer"
:class="{ 'theme-dark': isDark }"
:load-chunk="loadChunk"
@load-error="emit('load-error', $event)"
>
<div class="json-lines">
<template
v-for="line in visibleLines"
:key="line.displayId"
<template #default="{ chunk, index }">
<div
v-if="chunk.parseError && index === 0"
class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"
>
<div
class="json-line"
:class="{ 'has-fold': line.canFold }"
>
<!-- 行号区域(包含折叠按钮) -->
<div class="line-number-area">
<span
v-if="line.canFold"
class="fold-button"
@click="toggleFold(line.blockId)"
>
<ChevronRight
v-if="collapsedBlocks.has(line.blockId)"
class="fold-icon"
/>
<ChevronDown
v-else
class="fold-icon"
/>
</span>
<span class="line-number">{{ line.displayLineNumber }}</span>
</div>
<!-- 内容区域 -->
<div class="line-content-area">
<!-- 缩进 -->
<span
class="indent"
:style="{ width: `${line.indent * 16}px` }"
/>
<!-- 内容 -->
<!-- eslint-disable vue/no-v-html -->
<span
class="line-content"
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
v-html="getDisplayHtml(line)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<div class="flex items-start gap-2">
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
<span class="text-xs text-amber-700 dark:text-amber-300">{{ chunk.parseError }}</span>
</div>
</template>
</div>
</div>
</div>
<div
v-if="chunk.text !== undefined"
class="px-4"
:class="{ 'pt-4': index === 0, 'pb-4': !chunk.hasNext }"
>
<pre class="text-xs font-mono whitespace-pre-wrap break-all">{{ chunk.text }}</pre>
</div>
<div
v-else
class="json-lines"
>
<template
v-for="line in chunk.lines"
:key="line.id"
>
<div
class="json-line"
:class="{ 'has-fold': line.canFold }"
:data-json-line="line.lineNumber"
>
<!-- 行号区域(包含折叠按钮) -->
<div class="line-number-area">
<button
v-if="line.canFold"
class="fold-button"
type="button"
:aria-label="line.collapsed ? '展开节点' : '折叠节点'"
:aria-expanded="!line.collapsed"
@click="toggleFold(line, index)"
>
<ChevronRight
v-if="line.collapsed"
class="fold-icon"
/>
<ChevronDown
v-else
class="fold-icon"
/>
</button>
<span class="line-number">{{ line.continuation ? '' : line.lineNumber }}</span>
</div>
<!-- 内容区域 -->
<div class="line-content-area">
<!-- 缩进 -->
<span
class="indent"
:style="{ width: `${line.indent * 16}px` }"
/>
<!-- 内容 -->
<!-- eslint-disable vue/no-v-html -->
<span
class="line-content"
:class="{ 'clickable-collapsed': line.canFold && line.collapsed }"
@click="line.canFold && line.collapsed && toggleFold(line, index)"
v-html="getDisplayHtml(line)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</template>
</div>
</template>
</VirtualBodyContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import VirtualBodyContent from './VirtualBodyContent.vue'
import { getRawTextChunk, JsonPageReader, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE, type JsonDisplayLine } from '../../utils/json-viewer'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyJsonPage } from '../../utils/body-document-protocol'
interface JsonLine {
id: number
lineNumber: number
indent: number
html: string
canFold: boolean
blockId: string
blockEnd?: number
collapsedInfo?: string
closingBracket?: string
trailingComma?: string
}
interface DisplayLine extends JsonLine {
displayId: string
displayLineNumber: number
}
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
const props = defineProps<{
data: unknown
bodyDocument?: BodyDocument | null
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
emptyMessage: string
}>()
/** Safely cast data to an object for property access in templates */
const dataAsObject = computed(() => {
if (props.data && typeof props.data === 'object' && !Array.isArray(props.data)) {
return props.data as Record<string, unknown>
}
return null
})
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewer = ref<{ refresh: (index: number, resetTail?: boolean) => void } | null>(null)
const viewRevision = ref(0)
const foldOverrides = ref(new Map<string, boolean>())
let localReader: JsonPageReader | undefined
/** Whether the data contains a raw_response with a parse error (non-JSON response) */
const hasParseError = computed(() => {
const obj = dataAsObject.value
if (!obj) return false
const metadata = obj.metadata as Record<string, unknown> | undefined
return Boolean(obj.raw_response && metadata?.parse_error)
})
/** Parse error message */
const parseErrorMessage = computed(() => {
const obj = dataAsObject.value
if (!obj) return ''
const metadata = obj.metadata as Record<string, unknown> | undefined
return String(metadata?.parse_error || '')
})
/** Raw response content */
const rawResponseContent = computed(() => {
const obj = dataAsObject.value
return obj ? String(obj.raw_response || '') : ''
})
const collapsedBlocks = ref<Set<string>>(new Set())
const lines = ref<JsonLine[]>([])
const getTokenHtml = (value: string, type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'bracket' | 'punctuation' | 'ellipsis'): string => {
const classMap = {
key: 'token-key',
string: 'token-string',
number: 'token-number',
boolean: 'token-boolean',
null: 'token-null',
bracket: 'token-bracket',
punctuation: 'token-punctuation',
ellipsis: 'token-ellipsis',
}
return `<span class="${classMap[type]}">${escapeHtml(value)}</span>`
function loadChunk(index: number): BodyJsonPage | Promise<BodyJsonPage> {
if (props.bodyDocument) return props.bodyDocument.json({
page: index,
pageSize: JSON_SCROLL_CHUNK_SIZE,
expandDepth: props.expandDepth,
foldOverrides: new Map(foldOverrides.value),
})
const record = props.data && typeof props.data === 'object' ? props.data as Record<string, unknown> : null
const metadata = record?.metadata as Record<string, unknown> | undefined
const parseError = record?.raw_response && metadata?.parse_error ? String(metadata.parse_error) : undefined
const text = typeof props.data === 'string' ? props.data : parseError ? String(record?.raw_response) : undefined
if (text !== undefined) return { lines: [], ...getRawTextChunk(text, index), parseError }
localReader ??= new JsonPageReader(props.data, { pageSize: JSON_SCROLL_CHUNK_SIZE, expandDepth: props.expandDepth, foldOverrides: new Map(foldOverrides.value), stringChunkSize: JSON_TEXT_CHUNK_SIZE })
return localReader.read(index)
}
const escapeHtml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const jsonStringLiteral = (value: string): string => JSON.stringify(value)
function token(value: string, type: string): string {
return `<span class="token-${type}">${escapeHtml(value)}</span>`
}
const parseJsonToLines = (data: unknown): JsonLine[] => {
const result: JsonLine[] = []
let lineNumber = 1
let blockIdCounter = 0
const getBlockId = () => `block-${blockIdCounter++}`
const processValue = (value: unknown, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
const comma = isLast ? '' : ','
if (value === null) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('null', 'null') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'boolean') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'boolean') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'number') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'number') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'string') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(jsonStringLiteral(value), 'string') + comma,
canFold: false,
blockId: '',
})
} else if (Array.isArray(value)) {
if (value.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[]', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${value.length} items`,
closingBracket: ']',
trailingComma: comma,
})
value.forEach((item, i) => {
processValue(item, indent + 1, i === value.length - 1)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml(']', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else if (typeof value === 'object') {
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
if (keys.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{}', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${keys.length} keys`,
closingBracket: '}',
trailingComma: comma,
})
keys.forEach((key, i) => {
const keyHtml = getTokenHtml(jsonStringLiteral(key), 'key') + getTokenHtml(': ', 'punctuation')
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml('}', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'string') + comma,
canFold: false,
blockId: '',
})
function getDisplayHtml(line: JsonDisplayLine): string {
if (line.tokens) return line.tokens.map(part => part.type === 'info'
? `<span class="collapsed-info">${escapeHtml(part.text)}</span>` : token(part.text, part.type)).join('')
const key = line.key === undefined ? ''
: token(JSON.stringify(line.key), 'key') + token(': ', 'punctuation')
if (line.bracket) {
let content = key + token(line.bracket, 'bracket')
if (line.collapsed) {
if (line.childCount) content += token('...', 'ellipsis')
content += token(line.closingBracket || '', 'bracket') + line.comma
if (line.childCount) content += `<span class="collapsed-info">${line.childCount} ${line.isArray ? 'items' : 'keys'}</span>`
} else if (!line.canFold) {
content += line.comma
}
return content
}
processValue(data, 0, true)
return result
if (line.value === null) return key + token('null', 'null') + line.comma
if (typeof line.value === 'string') {
return key + token(JSON.stringify(line.value), 'string') + line.comma
}
const valueType = typeof line.value
return key + token(String(line.value), valueType === 'number' || valueType === 'boolean' ? valueType : 'string') + line.comma
}
const visibleLines = computed((): DisplayLine[] => {
const result: DisplayLine[] = []
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const line of lines.value) {
if (line.canFold && collapsedBlocks.value.has(line.blockId) && line.blockEnd !== undefined) {
hiddenRanges.push({ start: line.id + 1, end: line.blockEnd })
}
}
const isHidden = (id: number): boolean => {
return hiddenRanges.some(range => id >= range.start && id <= range.end)
}
let displayLineNumber = 1
for (const line of lines.value) {
if (!isHidden(line.id)) {
result.push({
...line,
displayId: `display-${line.id}`,
displayLineNumber: displayLineNumber++,
})
}
}
return result
})
const getDisplayHtml = (line: DisplayLine): string => {
if (line.canFold && collapsedBlocks.value.has(line.blockId)) {
const closingBracket = getTokenHtml(line.closingBracket || '}', 'bracket')
const ellipsis = getTokenHtml('...', 'ellipsis')
const comma = line.trailingComma || ''
return `${line.html}${ellipsis}${closingBracket}${comma}<span class="collapsed-info">${line.collapsedInfo}</span>`
}
return line.html
function toggleFold(line: JsonDisplayLine, index: number) {
const overrides = new Map(foldOverrides.value)
overrides.set(line.id, !line.collapsed)
foldOverrides.value = overrides
localReader = undefined
viewer.value?.refresh(index, true)
}
const toggleFold = (blockId: string) => {
const newSet = new Set(collapsedBlocks.value)
if (newSet.has(blockId)) {
newSet.delete(blockId)
} else {
newSet.add(blockId)
}
collapsedBlocks.value = newSet
}
const initCollapsedState = () => {
const newSet = new Set<string>()
// 默认展开第一层(indent = 0),折叠更深层(indent >= 1)
// expandDepth = 999 表示全部展开
const depth = props.expandDepth === 0 ? 1 : props.expandDepth
if (depth < 999) {
for (const line of lines.value) {
if (line.canFold && line.indent >= depth) {
newSet.add(line.blockId)
}
}
}
collapsedBlocks.value = newSet
}
watch(() => props.data, () => {
if (props.data) {
lines.value = parseJsonToLines(props.data)
initCollapsedState()
} else {
lines.value = []
}
}, { immediate: true })
watch(() => props.expandDepth, () => {
initCollapsedState()
watch([() => props.data, () => props.bodyDocument, () => props.expandDepth], () => {
viewRevision.value += 1
localReader = undefined
foldOverrides.value = new Map()
})
</script>
@@ -413,10 +191,6 @@ watch(() => props.expandDepth, () => {
line-height: 20px;
}
.json-lines {
padding: 4px 0;
}
.json-line {
display: flex;
min-height: 20px;
@@ -0,0 +1,210 @@
<template>
<div
ref="viewport"
class="virtual-body-scroll"
tabindex="0"
:aria-busy="loading"
@scroll.passive="readScrollPosition"
>
<div
aria-hidden="true"
:style="{ height: `${offsets[visibleStart]}px` }"
/>
<div
v-for="index in visibleIndexes"
:key="index"
:ref="element => setChunkElement(index, element)"
:data-body-chunk="index"
:style="{ minHeight: chunks.has(index) ? (chunks.get(index)!.hasNext ? '250px' : undefined) : `${heights[index]}px` }"
>
<slot
v-if="chunks.has(index)"
:chunk="chunks.get(index)!"
:index="index"
/>
<div
v-else
class="p-4 text-sm text-muted-foreground"
role="status"
>
{{ failed ? '正文视图加载失败' : '正在后台准备正文视图…' }}
</div>
</div>
<div
aria-hidden="true"
:style="{ height: `${offsets[heights.length] - offsets[visibleEnd]}px` }"
/>
</div>
</template>
<script setup lang="ts" generic="Chunk extends { hasNext: boolean }">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type ComponentPublicInstance } from 'vue'
const props = withDefaults(defineProps<{
loadChunk: (index: number) => Chunk | Promise<Chunk>
estimatedHeight?: number
}>(), { estimatedHeight: 1000 })
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewport = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(500)
const heights = shallowRef([props.estimatedHeight])
const chunks = shallowRef(new Map<number, Chunk>())
const loading = ref(false)
const failed = ref(false)
const elements = new Map<number, HTMLElement>()
let observer: ResizeObserver | undefined
let generation = 0
let disposed = false
const offsets = computed(() => {
const positions = [0]
for (const height of heights.value) positions.push(positions[positions.length - 1] + height)
return positions
})
function indexAt(position: number) {
let lower = 0
let upper = heights.value.length
while (lower < upper) {
const middle = Math.floor((lower + upper) / 2)
if (offsets.value[middle + 1] <= position) lower = middle + 1
else upper = middle
}
return Math.min(lower, heights.value.length - 1)
}
const visibleStart = computed(() => indexAt(Math.max(0, scrollTop.value - 250)))
const visibleEnd = computed(() => Math.min(
heights.value.length,
visibleStart.value + 4,
indexAt(scrollTop.value + viewportHeight.value + 250) + 1,
))
const visibleIndexes = computed(() => Array.from(
{ length: visibleEnd.value - visibleStart.value },
(_value, offset) => visibleStart.value + offset,
))
function readScrollPosition() {
if (!viewport.value) return
scrollTop.value = viewport.value.scrollTop
viewportHeight.value = viewport.value.clientHeight || 500
}
function measureChunks() {
if (!viewport.value || disposed) return
const updated = [...heights.value]
let adjustment = 0
let changed = false
for (const [index, element] of elements) {
if (!chunks.value.has(index) || index >= updated.length) continue
const height = element.getBoundingClientRect().height
if (height <= 0 || Math.abs(height - updated[index]) < 0.5) continue
if (offsets.value[index + 1] <= viewport.value.scrollTop) adjustment += height - updated[index]
updated[index] = height
changed = true
}
if (changed) {
heights.value = updated
if (adjustment) viewport.value.scrollTop += adjustment
}
readScrollPosition()
}
function setChunkElement(index: number, element: Element | ComponentPublicInstance | null) {
const previous = elements.get(index)
if (previous) observer?.unobserve(previous)
if (element instanceof HTMLElement) {
elements.set(index, element)
observer?.observe(element)
} else {
elements.delete(index)
}
}
function evictDistantChunks() {
const retained = new Map(chunks.value)
for (const index of retained.keys()) {
if (index < visibleStart.value - 1 || index > visibleEnd.value) retained.delete(index)
}
if (retained.size !== chunks.value.size) chunks.value = retained
}
async function loadVisibleChunks() {
if (loading.value || disposed || failed.value) return
loading.value = true
try {
while (!disposed && !failed.value) {
const index = visibleIndexes.value.find(candidate => !chunks.value.has(candidate))
if (index === undefined) break
const currentGeneration = generation
try {
const result = await props.loadChunk(index)
if (disposed) return
if (currentGeneration !== generation) continue
const updated = [...heights.value]
if (result.hasNext && index === updated.length - 1) updated.push(props.estimatedHeight)
if (!result.hasNext) updated.length = index + 1
heights.value = updated
chunks.value = new Map(chunks.value).set(index, result)
evictDistantChunks()
await nextTick()
measureChunks()
} catch (error) {
if (disposed) return
if (currentGeneration !== generation) continue
failed.value = true
emit('load-error', error)
}
}
} finally {
loading.value = false
}
}
function refresh(index: number, resetTail = false) {
generation += 1
failed.value = false
const retained = new Map(chunks.value)
for (const candidate of retained.keys()) {
if (candidate === index || (resetTail && candidate > index)) retained.delete(candidate)
}
chunks.value = retained
if (resetTail) heights.value = heights.value.slice(0, index + 1)
void loadVisibleChunks()
}
watch([visibleStart, visibleEnd], () => {
evictDistantChunks()
void loadVisibleChunks()
}, { immediate: true })
onMounted(() => {
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(measureChunks)
if (viewport.value) observer.observe(viewport.value)
for (const element of elements.values()) observer.observe(element)
}
window.addEventListener('resize', measureChunks)
measureChunks()
})
onBeforeUnmount(() => {
disposed = true
generation += 1
observer?.disconnect()
elements.clear()
window.removeEventListener('resize', measureChunks)
})
defineExpose({ refresh })
</script>
<style scoped>
.virtual-body-scroll {
max-height: 500px;
overflow: auto;
overflow-anchor: none;
}
</style>
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, shallowRef, type App } from 'vue'
import JsonContent from '../RequestDetailDrawer/JsonContent.vue'
import BodyConversationContent from '../RequestDetailDrawer/BodyConversationContent.vue'
import { BodyDocumentEngine } from '../../utils/body-document-engine'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyJsonOptions } from '../../utils/body-document-protocol'
const apps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => { for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() } })
function mountBody(value: unknown) {
const engine = new BodyDocumentEngine(value)
const json = vi.fn(async (options: BodyJsonOptions) => engine.json(options))
const bodyDocument = shallowRef({ json } as unknown as BodyDocument)
const expandDepth = shallowRef(999)
const errors: unknown[] = []
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(JsonContent, {
data: null, bodyDocument: bodyDocument.value, expandDepth: expandDepth.value, viewMode: 'formatted', isDark: false, emptyMessage: '无数据',
onLoadError: (error: unknown) => errors.push(error),
}) }))
app.mount(root)
apps.push({ app, root })
return { root, json, bodyDocument, expandDepth, engine, errors }
}
function button(root: HTMLElement, label: string) { return [...root.querySelectorAll('button')].find(button => button.textContent?.includes(label))! }
describe('worker-backed body views', () => {
it('resets every layer on collapse-all and reopens worker-backed nodes one layer at a time', async () => {
const value = { messages: [{ content: { text: 'deep worker content' } }] }
const { root, json, expandDepth, engine } = mountBody(value)
await vi.waitFor(() => expect(root.textContent).toContain('deep worker content'))
for (let cycle = 0; cycle < 2; cycle += 1) {
expandDepth.value = 0
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ expandDepth: 0, foldOverrides: new Map() }))
for (const lineCount of [5, 7, 9]) {
expect(root.textContent).not.toContain('deep worker content')
root.querySelector<HTMLButtonElement>('button[aria-label="展开节点"]')!.click()
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount))
}
expect(root.textContent).toContain('deep worker content')
expandDepth.value = 999
await vi.waitFor(() => expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ expandDepth: 999, foldOverrides: new Map() })))
await vi.waitFor(() => expect(root.textContent).toContain('deep worker content'))
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
}
expect(JSON.parse(engine.copy())).toEqual(value)
})
it('automatically reads worker chunks while scrolling and preserves the complete copy', async () => {
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
const { root, json, engine } = mountBody(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
expect(root.textContent).not.toMatch(/上一页|下一页|第 1 页/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 20; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(200)
}
expect(root.textContent).toContain('value-999')
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 20, pageSize: 50 }))
expect(JSON.parse(engine.copy())).toEqual(values)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('.line-number')?.textContent).toBe('1'))
})
it('streams every part of a long string without a show-more button', async () => {
const text = `${'x'.repeat(300_000) }STRING-END`
const { root, json, engine } = mountBody({ text })
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
expect(root.textContent).not.toMatch(/显示更多|继续显示|STRING-END/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 3; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(200)
}
expect(root.textContent).toContain('STRING-END')
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 3, pageSize: 50 }))
expect(JSON.parse(engine.copy())).toEqual({ text })
})
it('folds nodes further down without resetting scroll position or dropping later content', async () => {
const values = Array.from({ length: 300 }, (_value, index) => ({ content: `message-${index}` }))
const { root, json, engine } = mountBody(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 3; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
}
root.querySelector<HTMLButtonElement>('[data-body-chunk="3"] button[aria-label="折叠节点"]')!.click()
await vi.waitFor(() => expect(root.querySelector('[data-body-chunk="3"] button[aria-label="展开节点"]')).not.toBeNull())
expect(viewport.scrollTop).toBe(3000)
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 3, foldOverrides: expect.any(Map) }))
viewport.scrollTop = 4000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('[data-body-chunk="4"] .json-line')).not.toBeNull())
expect(JSON.parse(engine.copy())).toEqual(values)
})
it('ignores obsolete worker replies and propagates active worker failures', async () => {
const { root, bodyDocument, errors } = mountBody({ initial: true })
await vi.waitFor(() => expect(root.textContent).toContain('initial'))
let complete!: (value: ReturnType<BodyDocumentEngine['json']>) => void
bodyDocument.value = { json: () => new Promise(resolve => { complete = resolve }) } as unknown as BodyDocument
await nextTick()
bodyDocument.value = { json: async () => new BodyDocumentEngine({ replacement: true }).json() } as unknown as BodyDocument
await vi.waitFor(() => expect(root.textContent).toContain('replacement'))
complete(new BodyDocumentEngine({ stale: true }).json())
await nextTick()
expect(root.textContent).not.toContain('stale')
const error = new Error('worker failed')
bodyDocument.value = { json: async () => { throw error } } as unknown as BodyDocument
await vi.waitFor(() => expect(errors).toEqual([error]))
})
it('scrolls through bounded conversation previews without page controls or nested scrollbars', async () => {
const conversation = vi.fn(async ({ page }: { page: number }) => ({ result: { blocks: [{ type: 'text', content: `message ${page}` }] }, hasNext: page < 8, truncated: true }))
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(BodyConversationContent, {
bodyDocument: { conversation } as unknown as BodyDocument, kind: 'request', apiFormat: 'openai:chat', emptyMessage: '无数据',
}) }))
app.mount(root)
apps.push({ app, root })
await vi.waitFor(() => expect(root.textContent).toContain('message 0'))
expect(root.textContent).toContain('复制仍保留完整内容')
expect(root.textContent).not.toMatch(/上一页|下一页|对话第/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 8; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.textContent).toContain(`message ${index}`))
expect(root.querySelectorAll('[data-body-chunk]').length).toBeLessThanOrEqual(4)
}
expect(root.textContent).not.toContain('message 0')
expect(root.querySelectorAll('.overflow-y-auto')).toHaveLength(0)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.textContent).toContain('message 0'))
button(root, '显示更多').click()
await vi.waitFor(() => expect(conversation).toHaveBeenLastCalledWith(expect.objectContaining({ page: 0, previewLimit: 128_000 })))
})
})
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, shallowRef, type App } from 'vue'
import JsonContent from '../RequestDetailDrawer/JsonContent.vue'
import JsonContentPanel from '../JsonContentPanel.vue'
import { JSON_PAGE_SIZE, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE } from '../../utils/json-viewer'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
function mountJson(initialData: unknown, expandDepth = 999) {
const data = shallowRef(initialData)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(JsonContent, { data: data.value, expandDepth, isDark: false, viewMode: 'formatted', emptyMessage: '无数据' }),
}))
app.mount(root)
mountedApps.push({ app, root })
return { root, data }
}
describe('JsonContent lazy rendering', () => {
it('scrolls continuously to the last line and back with bounded DOM and complete data', async () => {
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
const { root, data } = mountJson(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(JSON_SCROLL_CHUNK_SIZE))
expect(root.textContent).not.toMatch(/上一页|下一页|第 1 页/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 20; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(JSON_PAGE_SIZE)
}
expect(root.textContent).toContain('value-999')
expect(root.textContent).not.toContain('value-0"')
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('.line-number')?.textContent).toBe('1'))
expect(root.textContent).toContain('value-0')
expect(data.value).toEqual(values)
data.value = { replacement: true }
await vi.waitFor(() => expect(root.textContent).toContain('replacement'))
expect(root.querySelector<HTMLElement>('.virtual-body-scroll')?.scrollTop).toBe(0)
})
it('opens one layer per bracket click after collapsing all descendants', async () => {
const { root } = mountJson({ messages: [{ content: { text: 'hidden content' } }] }, 0)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
for (const lineCount of [5, 7, 9]) {
expect(root.textContent).not.toContain('hidden content')
root.querySelector<HTMLElement>('.line-content.clickable-collapsed')!.click()
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount))
}
await vi.waitFor(() => expect(root.textContent).toContain('hidden content'))
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
})
it('collapses every layer through the toolbar and keeps expand-all working', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(JsonContentPanel, {
data: { messages: [{ content: { text: 'deep content' } }] }, isDark: false,
}) }))
app.mount(root)
mountedApps.push({ app, root })
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
for (let cycle = 0; cycle < 2; cycle += 1) {
root.querySelector<HTMLButtonElement>('button[title="展开全部"]')!.click()
await vi.waitFor(() => expect(root.textContent).toContain('deep content'))
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
root.querySelector<HTMLButtonElement>('button[title="收缩全部"]')!.click()
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
for (const lineCount of [5, 7]) {
root.querySelector<HTMLButtonElement>('button[aria-label="展开节点"]')!.click()
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount))
expect(root.textContent).not.toContain('deep content')
}
}
})
it('preserves an explicitly folded child when its parent is reopened', async () => {
const { root } = mountJson({ messages: [{ content: { text: 'hidden content' } }] })
const nodeButton = (key: string) => [...root.querySelectorAll('.json-line')]
.find(line => line.querySelector('.token-key')?.textContent === JSON.stringify(key))!
.querySelector<HTMLButtonElement>('button')!
await vi.waitFor(() => expect(root.textContent).toContain('hidden content'))
nodeButton('content').click()
await vi.waitFor(() => expect(nodeButton('content').getAttribute('aria-expanded')).toBe('false'))
nodeButton('messages').click()
await vi.waitFor(() => expect(nodeButton('messages').getAttribute('aria-expanded')).toBe('false'))
nodeButton('messages').click()
await vi.waitFor(() => expect(nodeButton('messages').getAttribute('aria-expanded')).toBe('true'))
expect(nodeButton('content').getAttribute('aria-expanded')).toBe('false')
expect(root.textContent).not.toContain('hidden content')
})
it('shows complete long strings without an extra expansion button', async () => {
const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂`
const { root, data } = mountJson({ content })
await vi.waitFor(() => expect(root.textContent).toContain('末尾🙂'))
const displayed = [...root.querySelectorAll('.token-string')].map(element => element.textContent).join('')
expect(displayed).toBe(JSON.stringify(content))
expect(root.textContent).not.toMatch(/显示更多|继续显示|剩余.*字符/)
expect(data.value).toEqual({ content })
})
it('automatically streams complete raw text and escapes HTML in JSON strings', async () => {
const text = `${'x'.repeat(100_000) }RAW-END`
const { root } = mountJson(text)
await vi.waitFor(() => expect(root.querySelector('pre')?.textContent).toHaveLength(16_000))
const chunks = [root.querySelector('pre')!.textContent]
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 6; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] pre`)).not.toBeNull())
chunks.push(root.querySelector(`[data-body-chunk="${index}"] pre`)!.textContent)
}
expect(chunks.join('')).toBe(text)
expect(root.textContent).toContain('RAW-END')
expect(root.querySelector('button')).toBeNull()
const escaped = mountJson({ content: '<img src=x onerror="alert(1)">'.repeat(200) }).root
await vi.waitFor(() => expect(escaped.querySelector('.token-string')).not.toBeNull())
expect(escaped.querySelector('img')).toBeNull()
expect(escaped.textContent).toContain('<img')
})
it.each([false, 0])('renders %s as a value rather than an empty body', async value => {
const { root } = mountJson(value)
await vi.waitFor(() => expect(root.textContent).toContain(String(value)))
expect(root.textContent).not.toContain('无数据')
})
})
@@ -1,130 +1,292 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import { AxiosError } from 'axios'
import type { RequestBodyField, RequestDetail } from '@/api/dashboard'
import { BodyDocumentError } from '../../utils/body-document-protocol'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn() }))
vi.mock('@/api/dashboard', async (importOriginal) => {
const mocks = vi.hoisted(() => ({ getRequestDetail: vi.fn(), getRequestBody: vi.fn(), load: vi.fn(), copyToClipboard: vi.fn() }))
vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: mocks.copyToClipboard }) }))
vi.mock('@/api/dashboard', async importOriginal => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail },
}
return { ...actual, dashboardApi: { ...actual.dashboardApi, getRequestDetail: mocks.getRequestDetail, getRequestBody: mocks.getRequestBody } }
})
vi.mock('../../utils/body-document', () => ({ BodyDocument: { load: mocks.load } }))
vi.mock('../HorizontalRequestTimeline.vue', () => ({ default: { render: () => null } }))
vi.mock('../JsonContentPanel.vue', async () => {
vi.mock('../RequestDetailDrawer/JsonContent.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: { type: null, default: null } },
setup(props) {
return () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.data))
},
}),
}
return { default: defineComponent({
props: { data: { type: null, default: null }, bodyDocument: { type: Object, default: null } },
setup: props => () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.bodyDocument?.display ?? props.data)),
}) }
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
const documents: Array<{ display: unknown, byteLength: number, dispose: ReturnType<typeof vi.fn>, copy: ReturnType<typeof vi.fn> }> = []
function body(value: unknown) { return { bytes: new TextEncoder().encode(JSON.stringify(value)).buffer, encoding: 'json' as const } }
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
beforeEach(() => {
mocks.getRequestDetail.mockImplementation(async id => ({ ...buildDetail(true), id, request_id: `req-${id}` }))
mocks.getRequestBody.mockImplementation(async (_id, field) => body({ text: field }))
mocks.copyToClipboard.mockResolvedValue(true)
mocks.load.mockImplementation(async (bytes: ArrayBuffer, _encoding, signal: AbortSignal) => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
const display = JSON.parse(new TextDecoder().decode(bytes))
const document = { display, byteLength: bytes.byteLength, dispose: vi.fn(), copy: vi.fn(async () => JSON.stringify(display, null, 2)) }
documents.push(document)
return document
})
})
afterEach(async () => {
for (const { app, root } of mountedApps.splice(0)) { app.unmount(); root.remove() }
await nextTick()
document.body.replaceChildren()
vi.useRealTimers()
vi.resetAllMocks()
documents.length = 0
})
function buildDetail(captured: boolean): RequestDetail {
return {
id: 'usage-full-capture',
request_id: 'req-full-capture',
id: 'usage-full-capture', request_id: 'req-full-capture',
user: { id: 'user-1', username: 'test-user', email: '[email protected]' },
api_key: { id: 'key-1', name: 'test-key', display: 'test-key' },
provider: 'test-provider',
api_format: 'openai:chat',
model: 'test-model',
tokens: { input: 10, output: 20, total: 30 },
cost: { input: 0, output: 0, total: 0 },
request_type: 'chat',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-09-07T00:00:00Z',
provider: 'test-provider', api_format: 'openai:chat', model: 'test-model',
tokens: { input: 10, output: 20, total: 30 }, cost: { input: 0, output: 0, total: 0 },
request_type: 'chat', is_stream: false, status: 'completed', status_code: 200,
response_time_ms: 10, created_at: '2026-09-07T00:00:00Z',
request_headers: { 'content-type': 'application/json', authorization: '[redacted]' },
has_request_body: captured,
has_provider_request_body: false,
has_response_body: captured,
has_client_response_body: false,
has_request_body: captured, has_provider_request_body: false,
has_response_body: captured, has_client_response_body: false,
}
}
async function openDrawer() {
const isOpen = ref(false)
const Host = defineComponent({
setup: () => () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-full-capture',
}),
})
const requestId = ref('usage-full-capture')
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
const app = createApp(defineComponent({ setup: () => () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: requestId.value }) }))
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('请求头')
})
await vi.waitFor(() => expect(findButton('请求头')).toBeDefined())
return { isOpen, requestId }
}
function findTab(label: string) {
return [...document.body.querySelectorAll('button')]
.find(button => button.textContent?.trim() === label)
function findButton(label: string) {
return [...document.body.querySelectorAll('button')].find(button => button.textContent?.trim() === label)
}
async function source(label: '客户端' | '提供商') {
await nextTick()
document.body.querySelector<HTMLButtonElement>(`button[title="${label}"]`)!.click()
await nextTick()
}
async function expectBody(text: string) {
await vi.waitFor(() => expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent).toContain(text))
}
function lastSignal() { return mocks.getRequestBody.mock.calls[mocks.getRequestBody.mock.calls.length - 1][2] as AbortSignal }
describe('RequestDetailDrawer body capture', () => {
it('keeps body tabs from shallow availability and loads full captures on demand', async () => {
const shallow = buildDetail(true)
const full: RequestDetail = {
...shallow,
request_body: { messages: [{ role: 'user', content: 'captured request text' }] },
response_body: { choices: [{ message: { role: 'assistant', content: 'captured response text' } }] },
it('displays original values for all four captured header directions and copies the selected headers', async () => {
const detail = {
...buildDetail(false),
request_headers: {
authorization: 'Bearer original-client-token',
originator: 'codex-cli',
'session-id': 'original-session',
'x-codex-turn-metadata': '{"turn_id":"original-turn"}',
},
provider_request_headers: { authorization: 'Bearer original-provider-token', 'x-api-key': 'original-provider-key' },
response_headers: { 'set-cookie': 'session=original-upstream', 'x-upstream-custom': 'original-upstream-value' },
client_response_headers: { 'set-cookie': 'session=original-client', 'x-client-custom': 'original-client-value' },
}
apiMocks.getRequestDetail.mockImplementation(async (_requestId, options) => (
options?.includeBodies ? full : shallow
))
mocks.getRequestDetail.mockResolvedValue(detail)
await openDrawer()
expect(findTab('请求体')).toBeDefined()
expect(findTab('响应体')).toBeDefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(apiMocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findTab('请求体')!.click()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured request text')
})
expect(apiMocks.getRequestDetail).toHaveBeenLastCalledWith('usage-full-capture', { includeBodies: true })
findTab('响应体')!.click()
await nextTick()
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured response text')
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(2)
for (const [tab, dataSource, expected] of [
['请求头', '客户端', detail.request_headers],
['请求头', '提供商', detail.provider_request_headers],
['响应头', '提供商', detail.response_headers],
['响应头', '客户端', detail.client_response_headers],
] as const) {
findButton(tab)!.click()
await source(dataSource)
await vi.waitFor(() => {
const text = document.body.querySelector('[data-testid="captured-body"]')?.textContent
expect(JSON.parse(text ?? 'null')).toEqual(expected)
})
}
document.body.querySelector<HTMLButtonElement>('button[title="复制"]')!.click()
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(detail.client_response_headers, null, 2), false))
expect(mocks.getRequestBody).not.toHaveBeenCalled()
})
it('does not offer body tabs or fetch uncaptured bodies for basic records', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildDetail(false))
it('uses only shallow details and loads a single binary body on demand', async () => {
await openDrawer()
expect(findTab('请求体')).toBeUndefined()
expect(findTab('响应体')).toBeUndefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(mocks.getRequestBody).not.toHaveBeenCalled()
expect(mocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findButton('请求体')!.click()
await expectBody('request_body')
expect(mocks.getRequestBody).toHaveBeenLastCalledWith('usage-full-capture', 'request_body', expect.any(AbortSignal))
findButton('响应体')!.click()
await expectBody('response_body')
findButton('请求体')!.click()
await expectBody('request_body')
expect(mocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('does not offer body tabs or fetch uncaptured basic records', async () => {
mocks.getRequestDetail.mockResolvedValue(buildDetail(false))
await openDrawer()
expect(findButton('请求体')).toBeUndefined()
expect(findButton('响应体')).toBeUndefined()
expect(mocks.getRequestBody).not.toHaveBeenCalled()
})
it('selects all four fields independently and evicts least-recently-used workers', async () => {
mocks.getRequestDetail.mockResolvedValue({ ...buildDetail(true), has_provider_request_body: true, has_client_response_body: true })
await openDrawer()
findButton('请求体')!.click()
await source('客户端')
await expectBody('request_body')
await source('提供商')
await expectBody('provider_request_body')
const client = documents.find(document => (document.display as { text: string }).text === 'request_body')!
findButton('响应体')!.click()
await source('提供商')
await expectBody('response_body')
expect(client.dispose).toHaveBeenCalledOnce()
await source('客户端')
await expectBody('client_response_body')
findButton('请求体')!.click()
await source('客户端')
await expectBody('request_body')
expect(mocks.getRequestBody.mock.calls.map(([, field]) => field)).toContain('provider_request_body')
expect(mocks.getRequestBody.mock.calls.filter(([, field]) => field === 'request_body')).toHaveLength(2)
})
it('cancels stale tab requests without caching their late responses', async () => {
let resolveBody!: (value: ReturnType<typeof body>) => void
mocks.getRequestBody.mockImplementationOnce(() => new Promise(resolve => { resolveBody = resolve }))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(mocks.getRequestBody).toHaveBeenCalledOnce())
const signal = lastSignal()
findButton('响应体')!.click()
await expectBody('response_body')
expect(signal.aborted).toBe(true)
resolveBody(body({ text: 'stale request' }))
await nextTick()
findButton('请求体')!.click()
await expectBody('request_body')
expect(document.body.textContent).not.toContain('stale request')
expect(mocks.getRequestBody).toHaveBeenCalledTimes(3)
})
it.each(['close', 'record'] as const)('cancels loading on %s and releases completed workers', async action => {
const host = await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
mocks.getRequestBody.mockImplementationOnce(() => new Promise(() => undefined))
findButton('响应体')!.click()
await vi.waitFor(() => expect(mocks.getRequestBody).toHaveBeenCalledTimes(2))
const signal = lastSignal()
if (action === 'close') host.isOpen.value = false
else host.requestId.value = 'usage-other'
await vi.waitFor(() => expect(signal.aborted).toBe(true))
await vi.waitFor(() => expect(documents[0].dispose).toHaveBeenCalledOnce())
})
it('shows network timeout details and retries only the active body', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new AxiosError('timeout', 'ECONNABORTED'))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文加载超时'))
findButton('重试')!.click()
await expectBody('request_body')
expect(mocks.getRequestBody.mock.calls.every(([, field]) => field === 'request_body')).toBe(true)
})
it('reopens on a lightweight tab without starting an obsolete body request', async () => {
const host = await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
host.isOpen.value = false
await nextTick()
host.isOpen.value = true
await vi.waitFor(() => expect(mocks.getRequestDetail).toHaveBeenCalledTimes(2))
expect(mocks.getRequestBody).toHaveBeenCalledOnce()
expect(documents[0].dispose).toHaveBeenCalledOnce()
})
it.each(['too_large', 'decode_failed'] as const)('isolates non-retryable %s worker failures from other bodies', async code => {
mocks.load.mockRejectedValueOnce(new BodyDocumentError(code))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain(code === 'too_large' ? '64 MiB' : '解析失败'))
expect(findButton('重试')).toBeUndefined()
findButton('响应体')!.click()
await expectBody('response_body')
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain(code === 'too_large' ? '64 MiB' : '解析失败'))
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('reports missing storage from binary response headers without parsing an error body', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new AxiosError('missing', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 404, statusText: 'Not Found', headers: { 'x-aether-body-error': 'missing' }, data: new ArrayBuffer(0), config: { headers: {} } as never,
}))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文暂不可用'))
expect(findButton('重试')).toBeDefined()
expect(mocks.load).not.toHaveBeenCalled()
})
it('does not pass invalid binary responses to the worker', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new Error('Invalid body response'))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文内容加载失败'))
expect(mocks.load).not.toHaveBeenCalled()
})
it('invalidates the worker cache when a streaming request finishes', async () => {
vi.useFakeTimers()
let finished = false
mocks.getRequestDetail.mockImplementation(async () => ({ ...buildDetail(true), status: finished ? 'completed' : 'streaming' }))
mocks.getRequestBody.mockImplementation(async (_id, field: RequestBodyField) => body({ field, text: finished ? 'final response' : 'partial response' }))
await openDrawer()
findButton('响应体')!.click()
await expectBody('partial response')
finished = true
await vi.advanceTimersByTimeAsync(1000)
await expectBody('final response')
expect(documents[0].dispose).toHaveBeenCalledOnce()
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('enforces the decoded-byte cache budget, not just a worker count', async () => {
await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
documents[0].byteLength = 64 * 1024 * 1024
findButton('响应体')!.click()
await expectBody('response_body')
expect(documents[0].dispose).toHaveBeenCalledOnce()
})
it('requests full copy from the worker only after an explicit click', async () => {
const value = { text: 'x'.repeat(100_000), last: 'complete content' }
mocks.getRequestBody.mockResolvedValue(body(value))
await openDrawer()
findButton('请求体')!.click()
await expectBody('complete content')
expect(documents[0].copy).not.toHaveBeenCalled()
document.body.querySelector<HTMLButtonElement>('button[title="复制"]')!.click()
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(value, null, 2), false))
expect(documents[0].copy).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import VirtualBodyContent from '../RequestDetailDrawer/VirtualBodyContent.vue'
type Chunk = { hasNext: boolean, text: string }
const apps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() }
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function mountChunks(loadChunk: (index: number) => Chunk | Promise<Chunk>) {
const errors: unknown[] = []
const viewer = ref<{ refresh: (index: number, resetTail?: boolean) => void } | null>(null)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(VirtualBodyContent<Chunk>, {
ref: viewer,
loadChunk,
onLoadError: (error: unknown) => errors.push(error),
}, { default: ({ chunk }: { chunk: Chunk }) => h('div', { class: 'chunk-text' }, chunk.text) }) }))
app.mount(root)
apps.push({ app, root })
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
return { app, root, viewport, viewer, errors }
}
function scroll(viewport: HTMLElement, top: number) {
viewport.scrollTop = top
viewport.dispatchEvent(new Event('scroll'))
}
describe('virtual body scrolling', () => {
it('keeps neighboring chunks for smooth boundaries, evicts distant ones and reloads on return', async () => {
const loadChunk = vi.fn((index: number) => ({ text: `chunk-${index}`, hasNext: index < 20 }))
const { root, viewport } = mountChunks(loadChunk)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
scroll(viewport, 800)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-1'))
expect(root.textContent).toContain('chunk-0')
scroll(viewport, 0)
await nextTick()
expect(loadChunk.mock.calls.filter(([index]) => index === 0)).toHaveLength(1)
for (let index = 1; index <= 12; index += 1) {
scroll(viewport, index * 1000)
await vi.waitFor(() => expect(root.textContent).toContain(`chunk-${index}`))
expect(root.querySelectorAll('[data-body-chunk]').length).toBeLessThanOrEqual(4)
}
expect(root.textContent).not.toContain('chunk-0')
scroll(viewport, 0)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
expect(loadChunk.mock.calls.filter(([index]) => index === 0)).toHaveLength(2)
})
it('deduplicates rapid scrolling, ignores obsolete refresh results and stops after the end', async () => {
const pending: Array<(value: Chunk) => void> = []
const loadChunk = vi.fn(() => new Promise<Chunk>(resolve => pending.push(resolve)))
const { root, viewport, viewer } = mountChunks(loadChunk)
for (let count = 0; count < 10; count += 1) scroll(viewport, 100)
await nextTick()
expect(loadChunk).toHaveBeenCalledTimes(1)
viewer.value!.refresh(0)
pending[0]({ text: 'obsolete', hasNext: true })
await vi.waitFor(() => expect(loadChunk).toHaveBeenCalledTimes(2))
expect(root.textContent).not.toContain('obsolete')
pending[1]({ text: 'complete', hasNext: false })
await vi.waitFor(() => expect(root.textContent).toContain('complete'))
scroll(viewport, 5000)
await nextTick()
expect(loadChunk).toHaveBeenCalledTimes(2)
})
it('ignores errors after unmount and disconnects its resize observer', async () => {
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = disconnect
})
let reject!: (reason: unknown) => void
const { app, errors } = mountChunks(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise }))
app.unmount()
apps.pop()!.root.remove()
reject(new Error('late failure'))
await nextTick()
expect(errors).toEqual([])
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('updates variable chunk heights while preserving an already-visible scroll anchor', async () => {
let notifyResize!: () => void
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { notifyResize = callback }
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
})
const measured = new Map([[0, 1000], [1, 1000], [2, 1000]])
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { height: measured.get(Number(this.dataset.bodyChunk)) ?? 0 } as DOMRect
})
const { root, viewport } = mountChunks(index => ({ text: `chunk-${index}`, hasNext: index < 3 }))
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
scroll(viewport, 1100)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-1'))
measured.set(0, 1600)
notifyResize()
await nextTick()
expect(viewport.scrollTop).toBe(1700)
expect(root.textContent).toContain('chunk-1')
})
it('fills short conversation chunks without leaving an empty visible window', async () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { height: this.hasAttribute('data-body-chunk') ? 250 : 0 } as DOMRect
})
const { root, viewport } = mountChunks(index => ({ text: `chunk-${index}`, hasNext: index < 20 }))
await vi.waitFor(() => expect(root.textContent).toContain('chunk-2'))
for (let position = 0; position < 2500; position += 100) {
scroll(viewport, position)
const expected = Math.floor(position / 250)
await vi.waitFor(() => expect(root.textContent).toContain(`chunk-${expected}`))
expect(root.querySelectorAll('.chunk-text').length).toBeLessThanOrEqual(4)
}
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { gzipSync } from 'node:zlib'
import { BodyDocumentEngine, decodeBody } from '../body-document-engine'
import { JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from '../json-viewer'
function bytes(value: string) { return new TextEncoder().encode(value).buffer }
function gzip(value: string) { return Uint8Array.from(gzipSync(value)).buffer }
describe('body document decoding', () => {
it.each(['gzip', 'json'] as const)('decodes %s off the UI protocol with a byte count', async encoding => {
const text = JSON.stringify({ text: '你好🙂', count: 0, enabled: false })
const decoded = await decodeBody(encoding === 'gzip' ? gzip(text) : bytes(text), encoding)
expect(decoded.value).toEqual(JSON.parse(text))
expect(decoded.byteLength).toBe(bytes(text).byteLength)
})
it('enforces decompressed size while streaming, including exact boundaries', async () => {
const text = JSON.stringify('x'.repeat(100_000))
await expect(decodeBody(gzip(text), 'gzip', text.length)).resolves.toHaveProperty('byteLength', text.length)
await expect(decodeBody(gzip(text), 'gzip', text.length - 1)).rejects.toHaveProperty('code', 'too_large')
await expect(decodeBody(bytes(text), 'json', text.length - 1)).rejects.toHaveProperty('code', 'too_large')
})
it('rejects corrupt gzip, invalid JSON and invalid UTF-8 with safe codes', async () => {
await expect(decodeBody(bytes('not gzip'), 'gzip')).rejects.toHaveProperty('code', 'decode_failed')
await expect(decodeBody(gzip('not json'), 'gzip')).rejects.toHaveProperty('code', 'decode_failed')
await expect(decodeBody(new Uint8Array([34, 255, 34]).buffer, 'json')).rejects.toHaveProperty('code', 'decode_failed')
})
})
describe('worker-owned body previews', () => {
it('sends bounded rows and strings, while copy preserves every byte of content', () => {
const value = { messages: Array.from({ length: 6000 }, () => ({ content: 'x'.repeat(4000) })) }
const document = new BodyDocumentEngine(value)
const page = document.json({ expandDepth: 999 })
expect(page.lines).toHaveLength(JSON_PAGE_SIZE)
expect(page.hasNext).toBe(true)
expect(page.lines.some(line => line.continuation)).toBe(true)
expect(page.lines.every(line => (line.tokens ?? []).reduce((length, token) => length + token.text.length, 0) <= JSON_TEXT_CHUNK_SIZE)).toBe(true)
const next = document.json({ page: 1, expandDepth: 999 })
expect(new Set([...page.lines, ...next.lines].map(line => line.id)).size).toBe(JSON_PAGE_SIZE * 2)
expect(JSON.parse(document.copy())).toEqual(value)
})
it('keeps collapsed children unread and identifiers small even with giant keys', () => {
const hidden = new Proxy({}, { ownKeys: () => { throw new Error('must remain lazy') } })
const document = new BodyDocumentEngine({ parent: { hidden } })
expect(document.json({ expandDepth: 0 }).lines).toHaveLength(3)
const key = 'k'.repeat(100_000)
const value = 'v'.repeat(10_000)
const giant = new BodyDocumentEngine({ [key]: value })
const lines = giant.json().lines
expect(lines.every(line => line.id.length < 40)).toBe(true)
expect(lines.filter(line => line.lineNumber === 2).flatMap(line => line.tokens ?? []).map(token => token.text).join('')).toBe(`${JSON.stringify(key)}: ${JSON.stringify(value)}`)
expect(JSON.parse(giant.copy())).toEqual({ [key]: value })
})
it('supports smaller scrolling batches without relaxing the worker transfer limit', () => {
const value = Array.from({ length: 1000 }, (_value, index) => `message-${index}`)
const document = new BodyDocumentEngine(value)
expect(document.json({ pageSize: 50 }).lines).toHaveLength(50)
expect(document.json({ pageSize: 50, page: 1 }).lines[0].lineNumber).toBe(51)
expect(document.json({ pageSize: 10_000 }).lines).toHaveLength(JSON_PAGE_SIZE)
expect(document.json({ pageSize: 50, page: 20 }).hasNext).toBe(false)
expect(JSON.parse(document.copy())).toEqual(value)
})
it('streams all raw text and parse-error response chunks without shipping the full string at once', () => {
const text = 'x'.repeat(100_000)
for (const value of [text, { raw_response: text, metadata: { parse_error: 'invalid upstream response' } }]) {
const document = new BodyDocumentEngine(value)
expect(document.json().text).toHaveLength(16_000)
expect(document.json().hasNext).toBe(true)
const chunks = []
for (let page = 0; page < 10; page += 1) {
const chunk = document.json({ page })
chunks.push(chunk.text)
if (!chunk.hasNext) break
}
expect(chunks.join('')).toBe(text)
expect(JSON.parse(document.copy())).toEqual(value)
}
})
it('paginates conversation blocks and bounds long text without truncating copy', () => {
const content = 'message content '.repeat(10_000)
const document = new BodyDocumentEngine({ model: 'test', messages: Array.from({ length: 40 }, () => ({ role: 'user', content })) })
const options = { kind: 'request' as const, apiFormat: 'openai:chat' }
const preview = document.conversationPage(options)
expect(preview.hasNext).toBe(true)
expect(preview.truncated).toBe(true)
expect(JSON.stringify(preview).length).toBeLessThan(70_000)
expect(document.copy(options)).toContain(content)
expect(document.conversationPage({ ...options, page: 1 }).result.blocks.length).toBeGreaterThan(0)
})
it.each([null, false, 0])('keeps the primitive %s as a valid document', value => {
const document = new BodyDocumentEngine(value)
expect(document.json().lines[0].value).toBe(value)
expect(document.copy()).toBe(JSON.stringify(value))
})
})
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BodyDocument } from '../body-document'
import { BODY_WORKER_TIMEOUT, type BodyWorkerResponse } from '../body-document-protocol'
class FakeWorker {
static instances: FakeWorker[] = []
onmessage: ((event: MessageEvent<BodyWorkerResponse>) => void) | null = null
onerror: (() => void) | null = null
onmessageerror: (() => void) | null = null
postMessage = vi.fn()
terminate = vi.fn()
constructor() { FakeWorker.instances.push(this) }
reply(data: BodyWorkerResponse) { this.onmessage?.({ data } as MessageEvent<BodyWorkerResponse>) }
}
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); FakeWorker.instances = [] })
function start() {
vi.stubGlobal('Worker', FakeWorker)
const bytes = new ArrayBuffer(16)
const controller = new AbortController()
const loading = BodyDocument.load(bytes, 'gzip', controller.signal)
const worker = FakeWorker.instances[FakeWorker.instances.length - 1]
return { bytes, controller, loading, worker }
}
describe('body worker lifecycle', () => {
it('transfers compressed bytes and receives only a summary before requesting pages', async () => {
const { bytes, loading, worker } = start()
expect(worker.postMessage).toHaveBeenCalledWith({ id: 1, action: 'load', bytes, encoding: 'gzip' }, [bytes])
worker.reply({ id: 1, ok: true, result: { byteLength: 100_000 } })
const document = await loading
expect(document.byteLength).toBe(100_000)
const page = document.json({ page: 0 })
worker.reply({ id: 2, ok: true, result: { lines: [], hasNext: true } })
await expect(page).resolves.toEqual({ lines: [], hasNext: true })
document.dispose()
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('terminates in-progress decompression on abort and ignores a late response', async () => {
const { loading, controller, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('name', 'AbortError')
controller.abort()
worker.reply({ id: 1, ok: true, result: { byteLength: 500 } })
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('does not return a disposed handle if abort races with the load response', async () => {
const { loading, controller, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('name', 'AbortError')
worker.reply({ id: 1, ok: true, result: { byteLength: 500 } })
controller.abort()
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('terminates workers that fail, cannot deserialize, or time out', async () => {
vi.useFakeTimers()
for (const mode of ['error', 'messageerror', 'timeout']) {
const { loading, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('code', mode === 'timeout' ? 'timeout' : 'worker_failed')
if (mode === 'error') worker.onerror?.()
else if (mode === 'messageerror') worker.onmessageerror?.()
else await vi.advanceTimersByTimeAsync(BODY_WORKER_TIMEOUT)
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
}
})
it('does not silently fall back to parsing on the main thread', async () => {
vi.stubGlobal('Worker', undefined)
await expect(BodyDocument.load(new ArrayBuffer(1), 'json')).rejects.toHaveProperty('code', 'unsupported')
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { AxiosError, type AxiosResponse } from 'axios'
import { formatRequestBodyLoadError, formatStoredBodyLoadError } from '../body-load-error'
import { RequestBodyProtocolError } from '@/api/dashboard'
describe('body load error messages', () => {
it('distinguishes timeouts, network failures, HTTP failures and storage errors', () => {
expect(formatRequestBodyLoadError(new AxiosError('timeout', 'ECONNABORTED'))).toContain('30 秒')
expect(formatRequestBodyLoadError(new AxiosError('offline', 'ERR_NETWORK'))).toContain('无法连接服务器')
for (const status of [403, 404, 500]) {
const error = new AxiosError('failed', undefined, undefined, undefined, { status } as AxiosResponse)
const expected = status === 403 ? '没有权限' : status === 404 ? '不存在' : 'HTTP 500'
expect(formatRequestBodyLoadError(error)).toContain(expected)
}
expect(formatStoredBodyLoadError('too_large')).toContain('64 MiB')
expect(formatStoredBodyLoadError('decode_failed')).toContain('解压或 JSON 解析失败')
expect(formatStoredBodyLoadError('missing')).toContain('正文暂不可用')
expect(formatStoredBodyLoadError('storage_unavailable')).toContain('读取正文存储失败')
expect(formatStoredBodyLoadError()).toContain('读取正文存储失败')
expect(formatRequestBodyLoadError(new RequestBodyProtocolError())).toContain('前后端均已更新')
})
})
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import { getJsonPage, getRawTextChunk, JsonPageReader, JSON_PAGE_SIZE, RAW_TEXT_CHUNK_SIZE } from '../json-viewer'
describe('lazy JSON pages', () => {
it('does not read descendants of collapsed nodes', () => {
const readContent = vi.fn(() => { throw new Error('collapsed content must not be read') })
const message = Object.defineProperty({}, 'largeContent', { enumerable: true, get: readContent })
const result = getJsonPage({ messages: [message] })
expect(result.lines).toHaveLength(3)
expect(result.lines[1]).toMatchObject({ id: '$/messages', collapsed: true, childCount: 1 })
expect(readContent).not.toHaveBeenCalled()
})
it('stops traversal after a page and one lookahead even when everything is expanded', () => {
const values = new Array(100_000).fill('small value')
const readBeyondPage = vi.fn(() => { throw new Error('off-page data must not be read') })
Object.defineProperty(values, JSON_PAGE_SIZE + 1, { get: readBeyondPage })
const result = getJsonPage(values, { expandDepth: 999 })
expect(result.lines).toHaveLength(JSON_PAGE_SIZE)
expect(result.hasNext).toBe(true)
expect(readBeyondPage).not.toHaveBeenCalled()
})
it('keeps complete ordered JSON lines across pages and collision-free node ids', () => {
const data = { nested: [{ value: 1 }, null, false, 0], 'nested:close': 'text', 'a/b': {}, 'a~1b': [] }
const expected = getJsonPage(data, { expandDepth: 999, pageSize: 1000 }).lines
const actual = []
for (let page = 0; page < 20; page += 1) {
const result = getJsonPage(data, { expandDepth: 999, pageSize: 3, page })
actual.push(...result.lines)
expect(result.lines.length).toBeLessThanOrEqual(3)
if (!result.hasNext) break
}
expect(actual).toEqual(expected)
expect(new Set(actual.map(line => line.id)).size).toBe(actual.length)
})
it('expands only explicitly opened descendants and preserves full string data', () => {
const content = 'large body '.repeat(10_000)
const data = { messages: [{ content }] }
const result = getJsonPage(data, { foldOverrides: new Map([['$/messages', false], ['$/messages/0', false]]) })
expect(result.lines.find(line => line.key === 'content')?.value).toBe(content)
expect(result.hasNext).toBe(false)
expect(data.messages[0].content).toBe(content)
})
it('reuses the traversal cursor for consecutive chunks and safely reloads evicted chunks', () => {
const reads = vi.fn()
const data = new Proxy(new Array(1000).fill('value'), {
get(target, key, receiver) {
if (typeof key === 'string' && /^\d+$/.test(key)) reads(key)
return Reflect.get(target, key, receiver)
},
})
const reader = new JsonPageReader(data, { pageSize: 50, expandDepth: 999 })
for (let page = 0; page < 20; page += 1) {
expect(reader.read(page).lines[0].lineNumber).toBe(page * 50 + 1)
}
expect(reads).toHaveBeenCalledTimes(1000)
expect(reader.read(19).lines[0].lineNumber).toBe(951)
expect(reads).toHaveBeenCalledTimes(1000)
expect(reader.read(0).lines[0].lineNumber).toBe(1)
expect(reader.read(20).lines[1]?.lineNumber).toBe(1002)
expect(reader.read(20).hasNext).toBe(false)
expect(reader.read(21).lines).toEqual([])
})
it.each([false, true])('opens collapsed descendants one layer at a time with indexPaths=%s', indexPaths => {
const data = { messages: [{ content: { text: 'complete content' } }] }
const paths = indexPaths ? ['$/0', '$/0/0', '$/0/0/0'] : ['$/messages', '$/messages/0', '$/messages/0/content']
const foldOverrides = new Map<string, boolean>()
for (const path of paths) {
const folded = getJsonPage(data, { expandDepth: 0, foldOverrides, indexPaths })
expect(folded.lines.find(line => line.id === path)).toMatchObject({ collapsed: true })
expect(folded.lines.some(line => line.value === 'complete content')).toBe(false)
foldOverrides.set(path, false)
}
const opened = getJsonPage(data, { expandDepth: 0, foldOverrides, indexPaths })
expect(opened.lines.some(line => line.value === 'complete content')).toBe(true)
})
it('does not read unopened descendants after opening their parent', () => {
const readContent = vi.fn(() => { throw new Error('unopened content must not be read') })
const message = Object.defineProperty({}, 'content', { enumerable: true, get: readContent })
const opened = getJsonPage({ messages: [message] }, { expandDepth: 0, foldOverrides: new Map([['$/messages', false]]) })
expect(opened.lines.find(line => line.id === '$/messages/0')).toMatchObject({ collapsed: true })
expect(readContent).not.toHaveBeenCalled()
})
it('still expands every layer in expand-all mode while honoring explicitly closed children', () => {
const data = { messages: [{ content: { text: 'complete content' } }] }
const expanded = getJsonPage(data, { expandDepth: 999 })
expect(expanded.lines.some(line => line.value === 'complete content')).toBe(true)
expect(expanded.lines.filter(line => line.canFold).every(line => !line.collapsed)).toBe(true)
const folded = getJsonPage(data, { expandDepth: 999, foldOverrides: new Map([['$/messages', false], ['$/messages/0/content', true]]) })
expect(folded.lines.some(line => line.value === 'complete content')).toBe(false)
})
it.each(['中文🙂', '"\\\n\r\t', '\u0000\b\f', '\ud800', '<img src=x>'])('streams complete escaped keys and strings for %j', text => {
const key = `key-${text.repeat(100)}`
const value = text.repeat(1000)
const reader = new JsonPageReader({ [key]: value }, { pageSize: 3, stringChunkSize: 31 })
const lines = []
for (let page = 0; page < 10_000; page += 1) {
const chunk = reader.read(page)
lines.push(...chunk.lines)
if (!chunk.hasNext) break
}
const tokens = lines.filter(line => line.lineNumber === 2).flatMap(line => line.tokens ?? [])
expect(tokens.map(token => token.text).join('')).toBe(`${JSON.stringify(key)}: ${JSON.stringify(value)}`)
expect(new Set(lines.map(line => line.id)).size).toBe(lines.length)
expect(tokens.every(token => token.text.length <= 31 && !/[\ud800-\udbff]$/.test(token.text))).toBe(true)
expect(lines.filter(line => line.lineNumber === 2 && !line.continuation)).toHaveLength(1)
})
it('keeps raw text Unicode pairs complete across automatic chunk boundaries', () => {
const value = `${'x'.repeat(RAW_TEXT_CHUNK_SIZE - 1) }🙂${ '中'.repeat(RAW_TEXT_CHUNK_SIZE) }RAW-END`
const first = getRawTextChunk(value)
const second = getRawTextChunk(value, 1)
const last = getRawTextChunk(value, 2)
expect(first.text.endsWith('🙂')).toBe(true)
expect(first.text + second.text + last.text).toBe(value)
expect(last.hasNext).toBe(false)
})
})
@@ -0,0 +1,144 @@
import { renderRequest, renderResponse, type RenderBlock, type RenderResult } from '../conversation'
import { getRawTextChunk, JsonPageReader, JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from './json-viewer'
import {
BodyDocumentError, MAX_BODY_BYTES, MAX_ENCODED_BODY_BYTES,
type BodyConversationOptions, type BodyConversationPage, type BodyEncoding,
type BodyJsonOptions, type BodyJsonPage,
} from './body-document-protocol'
export async function decodeBody(bytes: ArrayBuffer, encoding: BodyEncoding, limit = MAX_BODY_BYTES) {
if (bytes.byteLength > MAX_ENCODED_BODY_BYTES) throw new BodyDocumentError('too_large')
if (encoding === 'gzip' && typeof globalThis.DecompressionStream === 'undefined') {
throw new BodyDocumentError('unsupported')
}
const source = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(bytes))
controller.close()
},
})
const stream = encoding === 'gzip' ? source.pipeThrough(new DecompressionStream('gzip')) : source
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
const parts: string[] = []
let byteLength = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
byteLength += chunk.value.byteLength
if (byteLength > limit) throw new BodyDocumentError('too_large')
parts.push(decoder.decode(chunk.value, { stream: true }))
}
parts.push(decoder.decode())
return { value: JSON.parse(parts.join('')) as unknown, byteLength }
} catch (error) {
await reader.cancel().catch(() => undefined)
if (error instanceof BodyDocumentError) throw error
throw new BodyDocumentError('decode_failed')
} finally {
reader.releaseLock()
}
}
export class BodyDocumentEngine {
private conversation?: { key: string, result: RenderResult }
private jsonReader?: { key: string, reader: JsonPageReader }
constructor(private readonly value: unknown) {}
json(options: BodyJsonOptions = {}): BodyJsonPage {
const record = this.value && typeof this.value === 'object' ? this.value as Record<string, unknown> : null
const metadata = record?.metadata as Record<string, unknown> | undefined
const parseError = record?.raw_response && metadata?.parse_error ? String(metadata.parse_error) : undefined
const text = typeof this.value === 'string' ? this.value : parseError ? String(record?.raw_response) : undefined
if (text !== undefined) {
return { lines: [], ...getRawTextChunk(text, options.page), parseError: parseError?.slice(0, 2000) }
}
const pageSize = Math.min(JSON_PAGE_SIZE, Math.max(1, Math.trunc(options.pageSize ?? JSON_PAGE_SIZE)))
const key = JSON.stringify([pageSize, options.expandDepth, [...(options.foldOverrides ?? [])]])
if (this.jsonReader?.key !== key) {
this.jsonReader = { key, reader: new JsonPageReader(this.value, { ...options, pageSize, indexPaths: true, stringChunkSize: JSON_TEXT_CHUNK_SIZE }) }
}
return this.jsonReader.reader.read(options.page)
}
private render(options: BodyConversationOptions): RenderResult {
const key = `${options.kind}:${options.apiFormat ?? ''}`
if (this.conversation?.key !== key) {
this.conversation = { key, result: options.kind === 'request'
? renderRequest(this.value, undefined, options.apiFormat)
: renderResponse(this.value, undefined, options.apiFormat) }
}
return this.conversation.result
}
conversationPage(options: BodyConversationOptions): BodyConversationPage {
const result = this.render(options)
const first = Math.max(0, options.page ?? 0) * 10
let remaining = Math.min(Math.max(options.previewLimit ?? 64_000, 1000), 1_024_000)
let remainingBlocks = 200
let truncated = false
const clipString = (value: string) => {
const preview = value.slice(0, remaining)
remaining -= preview.length
if (preview.length === value.length) return value
truncated = true
return `${preview}…(内容较长,复制可获取完整内容)`
}
const clipBlocks = (blocks: RenderBlock[], depth = 0): RenderBlock[] => {
const previews: RenderBlock[] = []
for (const block of blocks) {
if (remainingBlocks <= 0 || remaining <= 0 || depth > 30) { truncated = true; break }
remainingBlocks -= 1
const preview = { ...block }
for (const key of Object.keys(preview)) {
const record = preview as unknown as Record<string, unknown>
const value = record[key]
if (typeof value === 'string' && key !== 'type' && key !== 'role') {
if (key === 'src' && value.length > remaining) {
record[key] = undefined
record.alt = '图片较大,请复制正文查看完整内容'
truncated = true
} else {
record[key] = clipString(value)
}
} else if (Array.isArray(value)) {
record[key] = clipBlocks(value, depth + 1)
}
}
previews.push(preview)
}
return previews
}
return {
result: { blocks: clipBlocks(result.blocks.slice(first, first + 10)), isStream: result.isStream, error: result.error?.slice(0, 2000) },
hasNext: result.blocks.length > first + 10,
truncated,
}
}
copy(conversation?: BodyConversationOptions): string {
if (!conversation) return JSON.stringify(this.value, null, 2)
const result = this.render(conversation)
if (result.error) return `[Error] ${result.error}`
return result.blocks.map(formatBlockAsText).filter(Boolean).join('\n\n---\n\n')
}
}
function formatBlockAsText(block: RenderBlock): string {
switch (block.type) {
case 'text': return block.content
case 'code': return `\`\`\`${block.language || ''}\n${block.code}\n\`\`\``
case 'collapsible': return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
case 'error': return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
case 'image': return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
case 'tool_use': return `[Tool: ${block.toolName}]\n${block.input}`
case 'tool_result': return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
case 'message': return `[${block.roleLabel || block.role}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')}`
case 'container': return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
case 'label': return `${block.label}: ${block.value}`
case 'divider': return '---'
case 'badge': return ''
}
}
@@ -0,0 +1,54 @@
import type { RenderResult } from '../conversation'
import type { JsonDisplayLine, JsonPageOptions } from './json-viewer'
export const MAX_BODY_BYTES = 64 * 1024 * 1024
export const MAX_ENCODED_BODY_BYTES = MAX_BODY_BYTES + 1024 * 1024
export const BODY_WORKER_TIMEOUT = 30_000
export type BodyEncoding = 'gzip' | 'json'
export type BodyDocumentErrorCode = 'too_large' | 'decode_failed' | 'unsupported' | 'worker_failed' | 'timeout'
export class BodyDocumentError extends Error {
constructor(public readonly code: BodyDocumentErrorCode) {
super(code)
this.name = 'BodyDocumentError'
}
}
export type BodyJsonOptions = JsonPageOptions
export interface BodyJsonPage {
lines: JsonDisplayLine[]
hasNext: boolean
text?: string
parseError?: string
}
export interface BodyConversationOptions {
kind: 'request' | 'response'
apiFormat?: string
page?: number
previewLimit?: number
}
export interface BodyConversationPage {
result: RenderResult
hasNext: boolean
truncated: boolean
}
export interface BodyDocumentSummary {
byteLength: number
}
export type BodyWorkerCommand =
| { action: 'load', bytes: ArrayBuffer, encoding: BodyEncoding }
| { action: 'json', options: BodyJsonOptions }
| { action: 'conversation', options: BodyConversationOptions }
| { action: 'copy', conversation?: BodyConversationOptions }
export type BodyWorkerResult = BodyDocumentSummary | BodyJsonPage | BodyConversationPage | string
export type BodyWorkerRequest = BodyWorkerCommand & { id: number }
export type BodyWorkerResponse = { id: number } & (
| { ok: true, result: BodyWorkerResult }
| { ok: false, code: BodyDocumentErrorCode }
)
@@ -0,0 +1,88 @@
import {
BODY_WORKER_TIMEOUT, BodyDocumentError,
type BodyConversationOptions, type BodyConversationPage, type BodyDocumentSummary,
type BodyEncoding, type BodyJsonOptions, type BodyJsonPage,
type BodyWorkerCommand, type BodyWorkerResponse, type BodyWorkerResult,
} from './body-document-protocol'
export class BodyDocument {
readonly kind = 'usage-body-document'
private sequence = 0
private disposed = false
private pending = new Map<number, { resolve: (result: BodyWorkerResult) => void, reject: (error: Error) => void, timer: ReturnType<typeof setTimeout> }>()
byteLength = 0
private constructor(private readonly worker: Worker) {
worker.onmessage = ({ data }: MessageEvent<BodyWorkerResponse>) => {
const pending = this.pending.get(data.id)
if (!pending) return
clearTimeout(pending.timer)
this.pending.delete(data.id)
if (data.ok) pending.resolve(data.result)
else pending.reject(new BodyDocumentError(data.code))
}
worker.onerror = () => this.dispose(new BodyDocumentError('worker_failed'))
worker.onmessageerror = () => this.dispose(new BodyDocumentError('worker_failed'))
}
static async load(bytes: ArrayBuffer, encoding: BodyEncoding, signal?: AbortSignal): Promise<BodyDocument> {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (typeof Worker === 'undefined') throw new BodyDocumentError('unsupported')
let document: BodyDocument
try {
document = new BodyDocument(new Worker(new URL('./body-document.worker.ts', import.meta.url), { type: 'module' }))
} catch {
throw new BodyDocumentError('worker_failed')
}
const abort = () => document.dispose()
signal?.addEventListener('abort', abort, { once: true })
try {
const result = await document.request<BodyDocumentSummary>({ action: 'load', bytes, encoding }, [bytes])
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
document.byteLength = result.byteLength
return document
} catch (error) {
document.dispose()
throw error
} finally {
signal?.removeEventListener('abort', abort)
}
}
private request<Result extends BodyWorkerResult>(command: BodyWorkerCommand, transfer: Transferable[] = []): Promise<Result> {
if (this.disposed) return Promise.reject(new BodyDocumentError('worker_failed'))
const id = ++this.sequence
return new Promise<Result>((resolve, reject) => {
const timer = setTimeout(() => this.dispose(new BodyDocumentError('timeout')), BODY_WORKER_TIMEOUT)
this.pending.set(id, { resolve: value => resolve(value as Result), reject, timer })
try {
this.worker.postMessage({ ...command, id }, transfer)
} catch {
this.dispose(new BodyDocumentError('worker_failed'))
}
})
}
json(options: BodyJsonOptions): Promise<BodyJsonPage> {
return this.request({ action: 'json', options })
}
conversation(options: BodyConversationOptions): Promise<BodyConversationPage> {
return this.request({ action: 'conversation', options })
}
copy(conversation?: BodyConversationOptions): Promise<string> {
return this.request({ action: 'copy', conversation })
}
dispose(error: Error = new DOMException('Aborted', 'AbortError')) {
if (this.disposed) return
this.disposed = true
this.worker.terminate()
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(error)
}
this.pending.clear()
}
}
@@ -0,0 +1,29 @@
import { BodyDocumentEngine, decodeBody } from './body-document-engine'
import { BodyDocumentError, type BodyWorkerRequest, type BodyWorkerResponse, type BodyWorkerResult } from './body-document-protocol'
const scope = globalThis as unknown as {
onmessage: (event: MessageEvent<BodyWorkerRequest>) => void
postMessage: (message: BodyWorkerResponse) => void
}
let document: BodyDocumentEngine | undefined
scope.onmessage = async ({ data: request }) => {
try {
let result: BodyWorkerResult
if (request.action === 'load') {
const decoded = await decodeBody(request.bytes, request.encoding)
document = new BodyDocumentEngine(decoded.value)
result = { byteLength: decoded.byteLength }
} else {
if (!document) throw new BodyDocumentError('worker_failed')
switch (request.action) {
case 'json': result = document.json(request.options); break
case 'conversation': result = document.conversationPage(request.options); break
case 'copy': result = document.copy(request.conversation); break
}
}
scope.postMessage({ id: request.id, ok: true, result })
} catch (error) {
scope.postMessage({ id: request.id, ok: false, code: error instanceof BodyDocumentError ? error.code : 'worker_failed' })
}
}
@@ -0,0 +1,40 @@
import axios from 'axios'
import { RequestBodyProtocolError, type RequestBodyLoadErrorCode } from '@/api/dashboard'
import { NETWORK_CONFIG } from '@/config/constants'
import { BodyDocumentError } from './body-document-protocol'
export function formatStoredBodyLoadError(code?: RequestBodyLoadErrorCode): string {
switch (code) {
case 'too_large':
return '正文超过 64 MiB 的安全读取上限,无法在线预览;重复重试无法解决此问题。'
case 'decode_failed':
return '正文解压或 JSON 解析失败,请检查该条记录的存储数据。'
case 'missing':
return '正文暂不可用,可能尚未写入或已被清理,请稍后重试。'
default:
return '读取正文存储失败,请稍后重试。'
}
}
export function formatRequestBodyLoadError(error: unknown): string {
if (error instanceof RequestBodyProtocolError) return '正文接口响应不匹配,请确认前后端均已更新后刷新页面。'
if (error instanceof BodyDocumentError) {
if (error.code === 'too_large' || error.code === 'decode_failed') return formatStoredBodyLoadError(error.code)
if (error.code === 'unsupported') return '当前浏览器不支持正文后台解压,请升级浏览器后重试。'
if (error.code === 'timeout') return '浏览器后台处理正文超时,请重试或使用性能更好的设备。'
return '正文后台处理失败,请重新加载正文。'
}
if (axios.isAxiosError(error)) {
const bodyCode = error.response?.headers?.['x-aether-body-error']
if (bodyCode === 'too_large' || bodyCode === 'missing' || bodyCode === 'storage_unavailable') return formatStoredBodyLoadError(bodyCode)
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
const seconds = Math.ceil((error.config?.timeout || NETWORK_CONFIG.API_TIMEOUT) / 1000)
return `正文加载超时(${seconds} 秒),请检查网络后重试。`
}
if (error.response?.status === 403) return '没有权限查看该正文。'
if (error.response?.status === 404) return '请求记录不存在或已被清理。'
if (error.response) return `正文加载失败(HTTP ${error.response.status}),请稍后重试。`
return '正文加载失败,无法连接服务器,请检查网络后重试。'
}
return '正文内容加载失败,请重试。'
}
@@ -0,0 +1,212 @@
export const JSON_PAGE_SIZE = 200
export const JSON_SCROLL_CHUNK_SIZE = 50
export const JSON_TEXT_CHUNK_SIZE = 2000
export const RAW_TEXT_CHUNK_SIZE = 16_000
export interface JsonDisplayToken {
text: string
type: string
}
export interface JsonDisplayLine {
id: string
lineNumber: number
indent: number
key?: string
value?: unknown
bracket?: string
closingBracket?: string
comma: string
canFold: boolean
collapsed: boolean
childCount?: number
isArray?: boolean
tokens?: JsonDisplayToken[]
continuation?: boolean
}
export interface JsonPageOptions {
page?: number
pageSize?: number
expandDepth?: number
foldOverrides?: ReadonlyMap<string, boolean>
indexPaths?: boolean
stringChunkSize?: number
}
type JsonTreeLine = Omit<JsonDisplayLine, 'lineNumber'>
function splitsSurrogatePair(value: string, position: number) {
const previous = value.charCodeAt(position - 1)
const next = value.charCodeAt(position)
return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff
}
export function getRawTextChunk(value: string, index = 0) {
const offset = Math.max(0, Math.trunc(index)) * RAW_TEXT_CHUNK_SIZE
const boundary = Math.min(offset + RAW_TEXT_CHUNK_SIZE, value.length)
const start = offset + (splitsSurrogatePair(value, offset) ? 1 : 0)
const end = boundary + (splitsSurrogatePair(value, boundary) ? 1 : 0)
return { text: value.slice(start, end), hasNext: end < value.length }
}
function* quotedTokens(value: string, type: string, chunkSize: number): Generator<JsonDisplayToken> {
yield { text: '"', type }
let offset = 0
while (offset < value.length) {
let end = Math.min(offset + chunkSize, value.length)
if (splitsSurrogatePair(value, end)) end -= 1
yield { text: JSON.stringify(value.slice(offset, end)).slice(1, -1), type }
offset = end
}
yield { text: '"', type }
}
function* lineTokens(line: JsonTreeLine, chunkSize: number): Generator<JsonDisplayToken> {
if (line.key !== undefined) {
yield* quotedTokens(line.key, 'key', chunkSize)
yield { text: ': ', type: 'punctuation' }
}
if (line.bracket) {
yield { text: line.bracket, type: 'bracket' }
if (line.collapsed) {
if (line.childCount) yield { text: '...', type: 'ellipsis' }
yield { text: line.closingBracket ?? '', type: 'bracket' }
yield { text: line.comma, type: 'punctuation' }
if (line.childCount) yield { text: `${line.childCount} ${line.isArray ? 'items' : 'keys'}`, type: 'info' }
} else if (!line.canFold) {
yield { text: line.comma, type: 'punctuation' }
}
} else {
if (typeof line.value === 'string') yield* quotedTokens(line.value, 'string', chunkSize)
else yield { text: String(line.value), type: line.value === null ? 'null' : typeof line.value }
yield { text: line.comma, type: 'punctuation' }
}
}
function* splitJsonLine(line: JsonTreeLine, chunkSize: number): Generator<JsonTreeLine> {
if ((line.key?.length ?? 0) + (typeof line.value === 'string' ? line.value.length : 0) + 10 <= chunkSize) {
yield line
return
}
let tokens: JsonDisplayToken[] = []
let length = 0
let part = 0
const fragment = (): JsonTreeLine => ({
...line,
id: part === 0 ? line.id : `fragment:${line.id}:${part}`,
key: undefined,
value: undefined,
tokens,
continuation: part > 0,
canFold: part === 0 && line.canFold,
})
for (const token of lineTokens(line, chunkSize)) {
let offset = 0
while (offset < token.text.length) {
let end = Math.min(offset + chunkSize - length, token.text.length)
if (splitsSurrogatePair(token.text, end)) end -= 1
if (end === offset) {
yield fragment()
tokens = []
length = 0
part += 1
continue
}
tokens.push({ type: token.type, text: token.text.slice(offset, end) })
length += end - offset
offset = end
if (length === chunkSize) {
yield fragment()
tokens = []
length = 0
part += 1
}
}
}
if (tokens.length) yield fragment()
}
function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<JsonDisplayLine> {
const depth = options.expandDepth === 0 || options.expandDepth == null ? 1 : options.expandDepth
function* walk(value: unknown, path: string, indent: number, comma: string, key?: string): Generator<JsonTreeLine> {
if (value == null || typeof value !== 'object') {
yield { id: path, indent, key, value, comma, canFold: false, collapsed: false }
return
}
const isArray = Array.isArray(value)
const keys = isArray ? [] : Object.keys(value)
const childCount = isArray ? value.length : keys.length
const override = options.foldOverrides?.get(path)
const collapsed = childCount === 0 || (override ?? (indent >= depth))
const bracket = isArray ? '[' : '{'
const closingBracket = isArray ? ']' : '}'
yield { id: path, indent, key, comma, bracket, closingBracket, childCount, isArray, collapsed, canFold: childCount > 0 }
if (collapsed) return
for (let index = 0; index < childCount; index += 1) {
const childKey = isArray ? String(index) : keys[index]
const childPath = `${path}/${options.indexPaths ? index : childKey.replace(/~/g, '~0').replace(/\//g, '~1')}`
const childValue = isArray ? value[index] : (value as Record<string, unknown>)[childKey]
yield* walk(childValue, childPath, indent + 1, index === childCount - 1 ? '' : ',', isArray ? undefined : childKey)
}
yield { id: `close:${path}`, indent, bracket: closingBracket, comma, canFold: false, collapsed: false }
}
let lineNumber = 0
for (const line of walk(data, '$', 0, '')) {
lineNumber += 1
if (options.stringChunkSize) {
for (const fragment of splitJsonLine(line, Math.max(2, options.stringChunkSize))) yield { ...fragment, lineNumber }
} else {
yield { ...line, lineNumber }
}
}
}
export class JsonPageReader {
private readonly pageSize: number
private iterator: Generator<JsonDisplayLine>
private nextLine: IteratorResult<JsonDisplayLine>
private nextPage = 0
private readonly cache = new Map<number, { lines: JsonDisplayLine[], hasNext: boolean }>()
constructor(private readonly data: unknown, private readonly options: JsonPageOptions = {}) {
this.pageSize = Math.max(1, Math.trunc(options.pageSize ?? JSON_PAGE_SIZE))
this.iterator = walkJsonLines(data, options)
this.nextLine = this.iterator.next()
}
read(page = 0): { lines: JsonDisplayLine[], hasNext: boolean } {
const requested = Math.max(0, Math.trunc(page))
const cached = this.cache.get(requested)
if (cached) return cached
if (requested < this.nextPage) {
this.iterator = walkJsonLines(this.data, this.options)
this.nextLine = this.iterator.next()
this.nextPage = 0
}
while (this.nextPage <= requested) {
const lines: JsonDisplayLine[] = []
while (lines.length < this.pageSize && !this.nextLine.done) {
lines.push(this.nextLine.value)
this.nextLine = this.iterator.next()
}
const result = { lines, hasNext: !this.nextLine.done }
this.cache.delete(this.nextPage)
this.cache.set(this.nextPage, result)
const oldest = this.cache.keys().next().value
if (this.cache.size > 6 && oldest !== undefined) this.cache.delete(oldest)
this.nextPage += 1
if (this.nextPage > requested) return result
if (!result.hasNext) return { lines: [], hasNext: false }
}
return { lines: [], hasNext: false }
}
}
export function getJsonPage(data: unknown, options: JsonPageOptions = {}) {
return new JsonPageReader(data, options).read(options.page)
}
@@ -29,6 +29,85 @@ describe('getCodexQuotaWindowPresentation', () => {
expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周'])
})
it('distinguishes account and model quotas with the same duration', () => {
const windows = [
{ code: 'weekly', label: '周', scope: 'account', window_minutes: 10_080 },
{ code: 'additional_0_primary', label: 'gpt-reserve', scope: 'model', window_minutes: 10_080 },
]
expect(windows.map(window => getCodexQuotaWindowPresentation(window)?.label))
.toEqual(['周', 'gpt-reserve 周'])
expect(getCodexQuotaWindowLimitLabel(windows[1])).toBe('gpt-reserve 周限额')
})
it.each([
[300, 'gpt-reserve 5H'],
[10_080, 'gpt-reserve 周'],
[43_800, 'gpt-reserve 月'],
])('includes the model name for a %i-minute window', (windowMinutes, expectedLabel) => {
expect(getCodexQuotaWindowPresentation({
code: 'additional_0_primary',
label: ' gpt-reserve ',
scope: ' Model ',
window_minutes: windowMinutes,
})?.label).toBe(expectedLabel)
})
it.each(['model', 'quota_group_label', 'quota_group'] as const)(
'falls back to %s when a model quota label is blank',
(field) => {
expect(getCodexQuotaWindowPresentation({
code: 'additional_0_primary',
label: ' ',
scope: 'model',
[field]: 'gpt-reserve',
window_minutes: 10_080,
})?.label).toBe('gpt-reserve 周')
},
)
it('keeps model identities when legacy snapshots have no window duration', () => {
expect(getCodexQuotaWindowPresentation({
code: 'additional_0_primary',
label: 'gpt-reserve',
scope: 'model',
})?.label).toBe('gpt-reserve')
expect(getCodexQuotaWindowPresentation({
code: 'additional_0_primary',
model: 'gpt-reserve',
scope: 'model',
})?.label).toBe('gpt-reserve')
expect(getCodexQuotaWindowPresentation({
code: 'weekly',
label: 'gpt-reserve',
scope: 'model',
})?.label).toBe('gpt-reserve 周')
})
it('uses the code when a model quota has no other identity', () => {
expect(getCodexQuotaWindowPresentation({
code: 'model-reserve',
scope: 'model',
window_minutes: 10_080,
})?.label).toBe('model-reserve 周')
})
it('preserves Spark formatting without stripping other model names', () => {
expect(getCodexQuotaWindowPresentation({
code: 'spark_weekly',
label: 'Spark 周',
window_minutes: 10_080,
})).toEqual({ label: 'Spark周', sortOrder: 10_010_080 })
expect(getCodexQuotaWindowPresentation({ code: 'spark_5h', label: 'Spark 5H' })?.label)
.toBe('Spark5H')
expect(getCodexQuotaWindowPresentation({
code: 'additional_0_primary',
label: 'Spark Reserve',
scope: 'model',
window_minutes: 10_080,
})?.label).toBe('Spark Reserve 周')
})
it('builds the provider limit label from the actual window duration', () => {
expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 10_080 })).toBe('周限额')
expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 43_800 })).toBe('月限额')
@@ -71,6 +71,37 @@ describe('providerKeyQuota', () => {
}, 'codex')).toBe('月剩余 86.0%')
})
it('keeps account and model Codex weekly quotas distinct in display text', () => {
expect(getQuotaDisplayText({
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
provider_type: 'codex',
code: 'ok',
exhausted: false,
windows: [
{
code: 'weekly',
label: '周',
scope: 'account',
window_minutes: 10_080,
remaining_ratio: 0.9,
},
{
code: 'additional_0_primary',
label: 'gpt-reserve',
scope: 'model',
model: 'gpt-reserve',
window_minutes: 10_080,
remaining_ratio: 0.4,
},
],
},
},
}, 'codex')).toBe('周剩余 90.0% | gpt-reserve 周剩余 40.0%')
})
it('formats Grok account quota from structured quota windows', () => {
expect(getQuotaDisplayText({
status_snapshot: {
+16 -3
View File
@@ -41,7 +41,13 @@ export function getCodexQuotaWindowPresentation(
const code = String(window.code || '').trim().toLowerCase()
const isSpark = code.startsWith('spark_')
const baseCode = isSpark ? code.slice('spark_'.length) : code
const rawLabel = String(window.label || '').trim().replace(/^Spark\s*/i, '')
const rawLabel = String(window.label || '').trim()
const modelLabel = String(window.scope || '').trim().toLowerCase() === 'model' && !isSpark
? [rawLabel, window.model, window.quota_group_label, window.quota_group, window.code]
.map(value => String(value || '').trim())
.find(Boolean)
: undefined
const legacyLabel = isSpark ? rawLabel.replace(/^Spark\s*/i, '') : rawLabel || modelLabel || ''
const hasExplicitWindowMinutes = window.window_minutes != null
const windowMinutes = Number(window.window_minutes)
@@ -51,12 +57,19 @@ export function getCodexQuotaWindowPresentation(
const period = hasExplicitWindowMinutes
? formatCodexQuotaPeriod(windowMinutes)
: getLegacyCodexQuotaPeriod(baseCode, rawLabel)
: getLegacyCodexQuotaPeriod(baseCode, legacyLabel)
if (!period) return null
let label = period
if (isSpark) {
label = `Spark${period}`
} else if (modelLabel && modelLabel !== period) {
label = `${modelLabel} ${period}`
}
const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000
return {
label: isSpark ? `Spark${period}` : period,
label,
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
}
}
@@ -63,7 +63,7 @@
<div
v-else-if="cardView"
class="grid grid-cols-1 gap-4 p-4 sm:p-6 md:grid-cols-2 2xl:grid-cols-3"
class="grid grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))] gap-4 p-4 sm:p-6"
>
<ProviderCard
v-for="provider in displayedProviders"
@@ -768,6 +768,60 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
})
it('renders separate account and model weekly quotas in desktop and mobile layouts', async () => {
const codexKey = createPoolKey('codex', {
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'codex',
windows: [
{
code: 'weekly',
label: '周',
scope: 'account',
remaining_ratio: 0.9,
window_minutes: 10_080,
usage: { request_count: 12 },
},
{
code: 'additional_0_primary',
label: 'gpt-reserve',
scope: 'model',
model: 'gpt-reserve',
remaining_ratio: 0.4,
window_minutes: 10_080,
usage: { request_count: 99 },
},
],
},
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const panels = root.querySelectorAll('[data-testid="pool-quota-rows"]')
expect(panels).toHaveLength(2)
for (const panel of panels) {
const rows = Array.from(panel.children).map(row => ({
label: row.querySelector('[data-testid="pool-quota-period-label"]')?.textContent?.trim(),
meter: row.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent?.trim(),
}))
expect(rows).toHaveLength(2)
expect(rows).toEqual(expect.arrayContaining([
{ label: '周', meter: '90.0%' },
{ label: 'gpt-reserve 周', meter: '40.0%' },
]))
}
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('-/12')
})
it('opens only one score popover across desktop and mobile layouts', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {
@@ -190,6 +190,32 @@ describe('ProviderManagement card view', () => {
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requests)
})
it('fills available width with shared grid columns and keeps bounded card content scrollable', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({
items: Array.from({ length: 5 }, (_, index) => createProvider({ id: `provider-${index + 1}` })),
total: 5,
})
localStorage.setItem('aether-provider-card-view', 'true')
const root = await mountView()
const card = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-1"]')!
const lastCard = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-5"]')!
const [header, content, actions] = Array.from(card.children)
expect(Array.from(card.classList)).toEqual(expect.arrayContaining(['max-h-96', 'w-full']))
expect(card.classList.contains('max-w-sm')).toBe(false)
expect(card.classList.contains('flex-1')).toBe(false)
expect(lastCard.className).toBe(card.className)
expect(lastCard.parentElement).toBe(card.parentElement)
expect(card.parentElement?.classList.contains('grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))]')).toBe(true)
expect(card.parentElement?.classList.contains('grid')).toBe(true)
expect(card.parentElement?.classList.contains('box-content')).toBe(false)
expect(card.parentElement?.classList.contains('items-start')).toBe(false)
expect(header.classList.contains('shrink-0')).toBe(true)
expect(Array.from(content.classList)).toEqual(expect.arrayContaining(['min-h-0', 'overflow-y-auto']))
expect(actions.classList.contains('shrink-0')).toBe(true)
expect(actions.contains(findButton(root, '查看详情'))).toBe(true)
})
it('remembers the chosen layout across remounts', async () => {
let root = await mountView()
findButton(root, '切换到卡片视图').click()
-42
View File
@@ -48,10 +48,6 @@ COMPOSE_LOG_ROTATION_DEFAULT="daily"
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
COMPOSE_APP_PORT_DEFAULT="8084"
COMPOSE_CONTAINER_UID_DEFAULT="65532"
COMPOSE_CONTAINER_GID_DEFAULT="65532"
COMPOSE_CONTAINER_UID=""
COMPOSE_CONTAINER_GID=""
COMPOSE_CLI=()
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
@@ -1764,41 +1760,6 @@ compose_app_port() {
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
}
validate_compose_container_id() {
local label="$1"
local value="$2"
[[ "${value}" =~ ^[1-9][0-9]*$ ]] \
|| die "${label} must be a positive numeric id, not root: ${value}"
[[ ${#value} -le 10 ]] \
|| die "${label} is outside the supported numeric id range: ${value}"
(( 10#${value} <= 2147483647 )) \
|| die "${label} is outside the supported numeric id range: ${value}"
}
resolve_new_compose_container_identity() {
local uid="${AETHER_CONTAINER_UID:-}"
local gid="${AETHER_CONTAINER_GID:-}"
if [[ -z "${uid}" || -z "${gid}" ]]; then
if [[ "${EUID}" -eq 0 && -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
uid="${uid:-${SUDO_UID}}"
gid="${gid:-${SUDO_GID}}"
elif [[ "${EUID}" -ne 0 ]]; then
uid="${uid:-$(id -u)}"
gid="${gid:-$(id -g)}"
else
uid="${uid:-${COMPOSE_CONTAINER_UID_DEFAULT}}"
gid="${gid:-${COMPOSE_CONTAINER_GID_DEFAULT}}"
fi
fi
validate_compose_container_id "AETHER_CONTAINER_UID" "${uid}"
validate_compose_container_id "AETHER_CONTAINER_GID" "${gid}"
COMPOSE_CONTAINER_UID="$((10#${uid}))"
COMPOSE_CONTAINER_GID="$((10#${gid}))"
}
append_compose_log_env_defaults() {
local output="$1"
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
@@ -1817,13 +1778,10 @@ generate_compose_env() {
encryption_key="$(urlsafe_rand 32)"
db_password="$(urlsafe_rand 32)"
redis_password="$(urlsafe_rand 32)"
resolve_new_compose_container_identity
cp "${COMPOSE_DIR}/.env.example" "${output}"
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
replace_or_append_env "${output}" "AETHER_CONTAINER_UID" "${COMPOSE_CONTAINER_UID}"
replace_or_append_env "${output}" "AETHER_CONTAINER_GID" "${COMPOSE_CONTAINER_GID}"
replace_or_append_env "${output}" "DB_PASSWORD" "${db_password}"
replace_or_append_env "${output}" "REDIS_PASSWORD" "${redis_password}"
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
+34 -5
View File
@@ -33,21 +33,31 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
capture_output=True, text=True, check=False,
)
env_file.write_text("DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n")
for files in (
database_environment = "DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n"
standard_compose_files = (
["docker-compose.yml"],
["docker-compose.yml", "docker-compose.local.yml"],
["docker-compose.single-node.yml"],
):
)
env_file.write_text(database_environment)
for files in standard_compose_files:
result = compose_config(files)
assert result.returncode == 0, result.stderr
config = json.loads(result.stdout)
assert set(config["services"]) == {"app", "postgres", "redis"}
assert set(config["volumes"]) == {"postgres_data"}
app_env = config["services"]["app"]["environment"]
app = config["services"]["app"]
assert app["user"] == "0:0"
assert app["read_only"] is True
assert app["cap_drop"] == ["ALL"]
assert set(app["cap_add"]) == {"DAC_OVERRIDE", "FOWNER"}
assert app["security_opt"] == ["no-new-privileges:true"]
assert not app.get("privileged", False)
app_env = app["environment"]
assert app_env["AETHER_DATABASE_DRIVER"] == "postgres"
assert app_env["DATABASE_URL"] == "postgresql://postgres:fixture-postgres@postgres:5432/aether"
assert app_env["REDIS_URL"] == "redis://:fixture-redis@redis:6379/0"
assert app_env["AETHER_LOG_DESTINATION"] == "stdout"
for key in ("DB_PASSWORD", "REDIS_PASSWORD"):
result = compose_config(files, overrides={key: ""})
assert result.returncode != 0, f"empty {key} was accepted"
@@ -65,4 +75,23 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
assert result.returncode != 0, "empty DB_PASSWORD was accepted"
assert "set DB_PASSWORD in .env" in result.stderr, result.stderr
print("PASS: PostgreSQL/Redis Compose configurations")
for log_destination in ("file", "both"):
env_file.write_text(
database_environment
+ f"AETHER_LOG_DESTINATION={log_destination}\nAETHER_LOG_DIR=/app/logs\n"
+ "AETHER_CONTAINER_UID=65532\nAETHER_CONTAINER_GID=65532\n"
)
for files in standard_compose_files:
result = compose_config(files)
assert result.returncode == 0, result.stderr
app = json.loads(result.stdout)["services"]["app"]
assert app["user"] == "0:0", files
assert app["environment"]["AETHER_LOG_DESTINATION"] == "stdout", files
assert all(
volume["target"] not in ("/app/logs", "/opt/aether/logs")
for volume in app.get("volumes", [])
), files
assert app["logging"]["driver"] == "json-file"
assert app["logging"]["options"] == {"max-size": "100m", "max-file": "10"}
print("PASS: PostgreSQL/Redis Compose configurations and legacy file logging overrides")
@@ -42,25 +42,23 @@ COMPOSE_FILES=(
"${REPO_ROOT}/docker-compose.single-node.yml"
)
assert_line "${APP_DOCKERFILE}" "USER 65532:65532"
assert_line "${APP_DOCKERFILE}" "USER 0:0"
assert_line "${REPO_ROOT}/Dockerfile.app.local" "USER 0:0"
assert_line "${REPO_ROOT}/Dockerfile.app.release-local" "USER 0:0"
assert_line "${APP_DOCKERFILE}" " HOME=/tmp/aether-home \\"
if grep -Eq '^USER[[:space:]]+(root|0)(:0)?[[:space:]]*$' "${APP_DOCKERFILE}"; then
fail_test "production image still selects a root runtime identity"
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
assert_line "${compose_file}" \
' user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"'
assert_line "${compose_file}" ' user: "0:0"'
assert_line "${compose_file}" " read_only: true"
assert_line "${compose_file}" " cap_drop:"
assert_line "${compose_file}" " - ALL"
assert_line "${compose_file}" " cap_add:"
assert_line "${compose_file}" " - DAC_OVERRIDE"
assert_line "${compose_file}" " - FOWNER"
assert_line "${compose_file}" " security_opt:"
assert_line "${compose_file}" " - no-new-privileges:true"
assert_line "${compose_file}" " tmpfs:"
assert_line "${compose_file}" " - /tmp:rw,nosuid,nodev,noexec,mode=1777"
if grep -Eq '^[[:space:]]+user:[[:space:]]+"?(root|0)(:0)?"?[[:space:]]*$' "${compose_file}"; then
fail_test "production Compose file still selects a root runtime identity: ${compose_file}"
fi
done
assert_line "${REPO_ROOT}/.env.example" "DB_PASSWORD="
@@ -84,19 +82,16 @@ mkdir -p "${fake_bin}"
printf '#!/usr/bin/env bash\nprintf "false\\n"\n' >"${fake_bin}/docker"
chmod 0755 "${fake_bin}/docker"
PATH="${fake_bin}:${PATH}"
fixture_uid="$(id -u)"
fixture_gid="$(id -g)"
[[ "${fixture_uid}" != "0" ]] || fixture_uid="65532"
[[ "${fixture_gid}" != "0" ]] || fixture_gid="65532"
cp "${REPO_ROOT}/.env.example" "${COMPOSE_DIR}/.env.example"
ADMIN_PASSWORD="test-admin-password"
APP_IMAGE="example.invalid/aether:test"
AETHER_CONTAINER_UID="${fixture_uid}"
AETHER_CONTAINER_GID="${fixture_gid}"
JWT_SECRET_KEY=""
ENCRYPTION_KEY=""
generated_env="${TEST_ROOT}/generated.env"
generate_compose_env "${generated_env}"
if grep -Eq '^AETHER_CONTAINER_(UID|GID)=' "${generated_env}"; then
fail_test "installer still generates obsolete non-root container identity settings"
fi
generated_secrets=()
for key in \
+28
View File
@@ -0,0 +1,28 @@
FROM ubuntu:24.04
COPY root-logging-tests /usr/local/bin/root-logging-tests
RUN set -eu; \
bucket="$(date -u +%Y-%m-%d)"; \
for entrypoint in standard reloadable; do \
for destination in file both; do \
for format in pretty json; do \
for owner in 0 1000 65532 new; do \
directory="/logs/${entrypoint}-${destination}-${format}-${owner}"; \
mkdir -p "${directory}"; \
chown 1000:1000 "${directory}"; \
chmod 0750 "${directory}"; \
if [ "${owner}" != new ]; then \
logfile="${directory}/root-logging-test.${bucket}.log"; \
printf 'historical log\n' >"${logfile}"; \
chown "${owner}:${owner}" "${logfile}"; \
chmod 0640 "${logfile}"; \
fi; \
done; \
done; \
done; \
done
USER 0:0
VOLUME ["/logs"]
ENTRYPOINT ["/usr/local/bin/root-logging-tests", "--ignored", "--exact", "root_appends_to_existing_logs_without_changing_ownership", "--nocapture"]