mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b766930b0 | ||
|
|
c7e403b410 | ||
|
|
cf8ea19856 | ||
|
|
7113d04f8a | ||
|
|
099b810a2f |
+5
-6
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+45
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`。若仍有权限解码错误,核对实际报错实例连接的数据库,以及是否有旧进程或外部工具继续写入旧格式;不要将其他类型的权限错误直接作为空值清除。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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('无数据')
|
||||
})
|
||||
})
|
||||
+251
-89
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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}}"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user