mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'origin/main' into codex/concurrency-hardening
This commit is contained in:
@@ -251,18 +251,6 @@ jobs:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
use_cross: true
|
use_cross: true
|
||||||
- name: macos-amd64
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: amd64
|
|
||||||
os: macos-15-intel
|
|
||||||
use_cross: false
|
|
||||||
- name: macos-arm64
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: arm64
|
|
||||||
os: macos-15
|
|
||||||
use_cross: false
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||||
with:
|
with:
|
||||||
@@ -398,14 +386,13 @@ jobs:
|
|||||||
VERSION="nightly"
|
VERSION="nightly"
|
||||||
|
|
||||||
mkdir -p package release-assets
|
mkdir -p package release-assets
|
||||||
for platform in linux macos; do
|
|
||||||
for arch in amd64 arm64; do
|
for arch in amd64 arm64; do
|
||||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
bundle="aether-${VERSION}-linux-${arch}"
|
||||||
root="package/${bundle}"
|
root="package/${bundle}"
|
||||||
mkdir -p "${root}/bin" "${root}/frontend"
|
mkdir -p "${root}/bin" "${root}/frontend"
|
||||||
|
|
||||||
install -m 0755 \
|
install -m 0755 \
|
||||||
"artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \
|
"artifacts/nightly-gateway-linux-${arch}/aether-gateway" \
|
||||||
"${root}/bin/aether-gateway"
|
"${root}/bin/aether-gateway"
|
||||||
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
|
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
|
||||||
sed \
|
sed \
|
||||||
@@ -423,7 +410,6 @@ jobs:
|
|||||||
|
|
||||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||||
done
|
done
|
||||||
done
|
|
||||||
|
|
||||||
sed \
|
sed \
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||||
@@ -432,8 +418,8 @@ jobs:
|
|||||||
chmod 0755 release-assets/install.sh
|
chmod 0755 release-assets/install.sh
|
||||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||||
|
|
||||||
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4
|
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 2
|
||||||
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
|
test "$(wc -l < release-assets/SHA256SUMS)" -eq 2
|
||||||
(cd release-assets && sha256sum -c SHA256SUMS)
|
(cd release-assets && sha256sum -c SHA256SUMS)
|
||||||
for archive in release-assets/*.tar.gz; do
|
for archive in release-assets/*.tar.gz; do
|
||||||
tar -tzf "${archive}" >/dev/null
|
tar -tzf "${archive}" >/dev/null
|
||||||
@@ -517,6 +503,15 @@ jobs:
|
|||||||
--repo "${REPOSITORY}" \
|
--repo "${REPOSITORY}" \
|
||||||
--clobber
|
--clobber
|
||||||
|
|
||||||
|
published_assets="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
|
||||||
|
while IFS= read -r asset_name; do
|
||||||
|
if [[ "${asset_name}" == aether-nightly-*.tar.gz && ! -f "release-assets/${asset_name}" ]]; then
|
||||||
|
gh release delete-asset "${RELEASE_TAG}" "${asset_name}" \
|
||||||
|
--repo "${REPOSITORY}" \
|
||||||
|
--yes
|
||||||
|
fi
|
||||||
|
done <<<"${published_assets}"
|
||||||
|
|
||||||
# target_commitish does not move an existing git tag. Move the ref
|
# target_commitish does not move an existing git tag. Move the ref
|
||||||
# only after the complete asset set is available.
|
# only after the complete asset set is available.
|
||||||
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||||
@@ -541,8 +536,6 @@ jobs:
|
|||||||
expected_assets=(
|
expected_assets=(
|
||||||
aether-nightly-linux-amd64.tar.gz
|
aether-nightly-linux-amd64.tar.gz
|
||||||
aether-nightly-linux-arm64.tar.gz
|
aether-nightly-linux-arm64.tar.gz
|
||||||
aether-nightly-macos-amd64.tar.gz
|
|
||||||
aether-nightly-macos-arm64.tar.gz
|
|
||||||
SHA256SUMS
|
SHA256SUMS
|
||||||
install.sh
|
install.sh
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,18 +186,6 @@ jobs:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
use_cross: true
|
use_cross: true
|
||||||
- name: macos-amd64
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: amd64
|
|
||||||
os: macos-15-intel
|
|
||||||
use_cross: false
|
|
||||||
- name: macos-arm64
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: arm64
|
|
||||||
os: macos-15
|
|
||||||
use_cross: false
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||||
|
|
||||||
@@ -356,15 +344,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p package release-assets
|
mkdir -p package release-assets
|
||||||
for platform in linux macos; do
|
|
||||||
for arch in amd64 arm64; do
|
for arch in amd64 arm64; do
|
||||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
bundle="aether-${VERSION}-linux-${arch}"
|
||||||
root="package/${bundle}"
|
root="package/${bundle}"
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"${root}/bin" \
|
"${root}/bin" \
|
||||||
"${root}/frontend"
|
"${root}/frontend"
|
||||||
|
|
||||||
install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
install -m 0755 "artifacts/aether-gateway-linux-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
||||||
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
||||||
sed \
|
sed \
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||||
@@ -381,7 +368,6 @@ jobs:
|
|||||||
|
|
||||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||||
done
|
done
|
||||||
done
|
|
||||||
|
|
||||||
sed \
|
sed \
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ cd Aether
|
|||||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
||||||
```
|
```
|
||||||
|
|
||||||
原生 Linux systemd / macOS launchd 安装需先准备 PostgreSQL,将连接串通过 `DATABASE_URL` 传给安装进程,并选择 `--mode single-node`;不再自动创建本地数据库文件。
|
正式版和 Nightly 自动构建仅提供 Linux `amd64` / `arm64` 二进制包,Docker 镜像同样支持这两种架构。macOS 用户可使用 Docker 或自行从源码构建;安装脚本保留对历史 macOS 制品的兼容。独立 Aether Tunnel 的多平台发行不受此调整影响。
|
||||||
|
|
||||||
|
原生 Linux systemd 安装需先准备 PostgreSQL,将连接串通过 `DATABASE_URL` 传给安装进程,并选择 `--mode single-node`;不再自动创建本地数据库文件。
|
||||||
|
|
||||||
### Nightly(每日 main 构建)
|
### Nightly(每日 main 构建)
|
||||||
|
|
||||||
|
|||||||
@@ -103,10 +103,11 @@ pub(crate) async fn maybe_build_local_admin_provider_reads_response(
|
|||||||
.build_admin_provider_summary_payload(&provider_id)
|
.build_admin_provider_summary_payload(&provider_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Some(payload) => Json(payload).into_response(),
|
Ok(Some(payload)) => Json(payload).into_response(),
|
||||||
None => build_admin_provider_not_found_response(format!(
|
Ok(None) => build_admin_provider_not_found_response(format!(
|
||||||
"Provider {provider_id} 不存在"
|
"Provider {provider_id} 不存在"
|
||||||
)),
|
)),
|
||||||
|
Err(_) => build_admin_providers_data_unavailable_response(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,16 +173,17 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
|
|||||||
.build_admin_provider_summary_payload(&provider_id)
|
.build_admin_provider_summary_payload(&provider_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Some(payload) => attach_admin_audit_response(
|
Ok(Some(payload)) => attach_admin_audit_response(
|
||||||
Json(payload).into_response(),
|
Json(payload).into_response(),
|
||||||
"admin_provider_updated",
|
"admin_provider_updated",
|
||||||
"update_provider",
|
"update_provider",
|
||||||
"provider",
|
"provider",
|
||||||
&provider_id,
|
&provider_id,
|
||||||
),
|
),
|
||||||
None => build_admin_provider_not_found_response(format!(
|
Ok(None) => build_admin_provider_not_found_response(format!(
|
||||||
"Provider {provider_id} 不存在"
|
"Provider {provider_id} 不存在"
|
||||||
)),
|
)),
|
||||||
|
Err(_) => build_admin_providers_data_unavailable_response(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::value::build_admin_provider_summary_value;
|
use super::value::build_admin_provider_summary_value;
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
|
use crate::GatewayError;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
@@ -10,19 +11,23 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
pub(crate) async fn build_admin_provider_summary_payload(
|
pub(crate) async fn build_admin_provider_summary_payload(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Option<serde_json::Value> {
|
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||||
let state = state.as_ref();
|
let state = state.as_ref();
|
||||||
if !state.has_provider_catalog_data_reader() {
|
if !state.has_provider_catalog_data_reader() {
|
||||||
return None;
|
return Err(GatewayError::Internal(
|
||||||
|
"Admin provider catalog data unavailable".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_ids = vec![provider_id.to_string()];
|
let provider_ids = vec![provider_id.to_string()];
|
||||||
let provider = state
|
let Some(provider) = state
|
||||||
.read_provider_catalog_providers_by_ids(&provider_ids)
|
.read_provider_catalog_providers_by_ids(&provider_ids)
|
||||||
.await
|
.await?
|
||||||
.ok()?
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()?;
|
.next()
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let (
|
let (
|
||||||
endpoints_result,
|
endpoints_result,
|
||||||
keys_result,
|
keys_result,
|
||||||
@@ -36,8 +41,8 @@ pub(crate) async fn build_admin_provider_summary_payload(
|
|||||||
state.list_provider_model_stats(&provider_ids),
|
state.list_provider_model_stats(&provider_ids),
|
||||||
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
||||||
);
|
);
|
||||||
let endpoints = endpoints_result.ok().unwrap_or_default();
|
let endpoints = endpoints_result?;
|
||||||
let keys = keys_result.ok().unwrap_or_default();
|
let keys = keys_result?;
|
||||||
let quota_snapshot = quota_snapshot_result.ok().flatten();
|
let quota_snapshot = quota_snapshot_result.ok().flatten();
|
||||||
let model_stats = model_stats_result
|
let model_stats = model_stats_result
|
||||||
.ok()
|
.ok()
|
||||||
@@ -57,7 +62,7 @@ pub(crate) async fn build_admin_provider_summary_payload(
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
Some(build_admin_provider_summary_value(
|
Ok(Some(build_admin_provider_summary_value(
|
||||||
&provider,
|
&provider,
|
||||||
&endpoints,
|
&endpoints,
|
||||||
&keys,
|
&keys,
|
||||||
@@ -65,7 +70,7 @@ pub(crate) async fn build_admin_provider_summary_payload(
|
|||||||
model_stats.as_ref(),
|
model_stats.as_ref(),
|
||||||
active_global_model_ids,
|
active_global_model_ids,
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn build_admin_providers_summary_payload(
|
pub(crate) async fn build_admin_providers_summary_payload(
|
||||||
@@ -94,11 +99,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
normalized_api_format != "all" && !normalized_api_format.is_empty();
|
normalized_api_format != "all" && !normalized_api_format.is_empty();
|
||||||
let requires_model_filter = normalized_model_id != "all" && !normalized_model_id.is_empty();
|
let requires_model_filter = normalized_model_id != "all" && !normalized_model_id.is_empty();
|
||||||
|
|
||||||
let mut providers = state
|
let mut providers = state.list_provider_catalog_providers(false).await.ok()?;
|
||||||
.list_provider_catalog_providers(false)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let all_provider_ids = providers
|
let all_provider_ids = providers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|provider| provider.id.clone())
|
.map(|provider| provider.id.clone())
|
||||||
@@ -109,8 +110,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
state
|
state
|
||||||
.list_provider_catalog_endpoints_by_provider_ids(&all_provider_ids)
|
.list_provider_catalog_endpoints_by_provider_ids(&all_provider_ids)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()?
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
};
|
||||||
let active_global_model_refs = if !requires_model_filter || all_provider_ids.is_empty() {
|
let active_global_model_refs = if !requires_model_filter || all_provider_ids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -202,8 +202,8 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
endpoints_result.ok().unwrap_or_default(),
|
endpoints_result.ok()?,
|
||||||
keys_result.ok().unwrap_or_default(),
|
keys_result.ok()?,
|
||||||
model_stats_result.ok().unwrap_or_default(),
|
model_stats_result.ok().unwrap_or_default(),
|
||||||
active_global_model_refs_result.ok().unwrap_or_default(),
|
active_global_model_refs_result.ok().unwrap_or_default(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,8 +95,11 @@ pub(crate) fn build_admin_provider_summary_value(
|
|||||||
let scores = endpoint_keys
|
let scores = endpoint_keys
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|key| endpoint.is_active && key.is_active)
|
.filter(|key| endpoint.is_active && key.is_active)
|
||||||
.filter_map(|key| provider_key_health_score(key, &endpoint.api_format))
|
.map(|key| {
|
||||||
|
provider_key_health_score(key, &endpoint.api_format)
|
||||||
.filter(|score| score.is_finite())
|
.filter(|score| score.is_finite())
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let health_score =
|
let health_score =
|
||||||
(!scores.is_empty()).then(|| scores.iter().sum::<f64>() / scores.len() as f64);
|
(!scores.is_empty()).then(|| scores.iter().sum::<f64>() / scores.len() as f64);
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
pub(crate) async fn build_admin_provider_summary_payload(
|
pub(crate) async fn build_admin_provider_summary_payload(
|
||||||
&self,
|
&self,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Option<serde_json::Value> {
|
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||||
crate::handlers::admin::provider::summary::build_admin_provider_summary_payload(
|
crate::handlers::admin::provider::summary::build_admin_provider_summary_payload(
|
||||||
self,
|
self,
|
||||||
provider_id,
|
provider_id,
|
||||||
|
|||||||
@@ -3074,6 +3074,45 @@ mod tests {
|
|||||||
.expect("key transport should build")
|
.expect("key transport should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_provider_key_health_response_preserves_v0_7_13_defaults() {
|
||||||
|
let state = AppState::new().expect("gateway should build");
|
||||||
|
for (health, expected_score) in [
|
||||||
|
(None, json!(1.0)),
|
||||||
|
(Some(json!({})), json!(1.0)),
|
||||||
|
(
|
||||||
|
Some(json!({"openai:chat": {"consecutive_failures": 0}})),
|
||||||
|
json!(1.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(json!({"openai:chat": {"health_score": 0.0}})),
|
||||||
|
json!(0.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(json!({"openai:chat": {"health_score": 1.0}})),
|
||||||
|
json!(1.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(json!({
|
||||||
|
"openai:chat": {"health_score": 0.25},
|
||||||
|
"openai:responses": {"health_score": 0.75},
|
||||||
|
})),
|
||||||
|
json!(0.25),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut key = sample_catalog_key();
|
||||||
|
key.health_by_format = health;
|
||||||
|
let payload = build_admin_provider_key_response(
|
||||||
|
&state,
|
||||||
|
&key,
|
||||||
|
"openai",
|
||||||
|
&["openai:chat".to_string()],
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
assert_eq!(payload["health_score"], expected_score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn responses_key_scope_covers_search_in_one_direction() {
|
fn responses_key_scope_covers_search_in_one_direction() {
|
||||||
let mut responses_key = sample_catalog_key();
|
let mut responses_key = sample_catalog_key();
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ use crate::data::GatewayDataState;
|
|||||||
|
|
||||||
const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable";
|
const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable";
|
||||||
|
|
||||||
|
mod health;
|
||||||
|
|
||||||
async fn provider_health_summary(
|
async fn provider_health_summary(
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
keys: &[StoredProviderCatalogKey],
|
keys: &[StoredProviderCatalogKey],
|
||||||
@@ -170,7 +172,7 @@ async fn admin_provider_summary_health_ignores_disabled_keys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_missing_data() {
|
async fn admin_provider_summary_health_averages_v0_7_13_defaults_with_observed_scores() {
|
||||||
let endpoint = sample_endpoint(
|
let endpoint = sample_endpoint(
|
||||||
"endpoint-chat",
|
"endpoint-chat",
|
||||||
"provider-openai",
|
"provider-openai",
|
||||||
@@ -183,21 +185,52 @@ async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_mis
|
|||||||
sample_key("key-unobserved", "provider-openai", "openai:chat", "test"),
|
sample_key("key-unobserved", "provider-openai", "openai:chat", "test"),
|
||||||
sample_key("key-other-format", "provider-openai", "openai:chat", "test")
|
sample_key("key-other-format", "provider-openai", "openai:chat", "test")
|
||||||
.with_health_fields(
|
.with_health_fields(
|
||||||
Some(json!({"openai:responses": {"health_score": 1.0}})),
|
Some(json!({"openai:responses": {"health_score": 0.0}})),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let payload = provider_health_summary(&[endpoint], &keys).await;
|
let payload = provider_health_summary(&[endpoint], &keys).await;
|
||||||
|
|
||||||
assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.2);
|
let expected_score = (0.2 + 1.0 + 1.0) / 3.0;
|
||||||
|
assert_eq!(
|
||||||
|
payload["endpoint_health_details"][0]["health_score"],
|
||||||
|
expected_score
|
||||||
|
);
|
||||||
assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 3);
|
assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 3);
|
||||||
assert_eq!(payload["avg_health_score"], 0.2);
|
assert_eq!(payload["avg_health_score"], expected_score);
|
||||||
assert_eq!(payload["unhealthy_endpoints"], 1);
|
assert_eq!(payload["unhealthy_endpoints"], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_provider_summary_health_is_unknown_without_active_observations() {
|
async fn admin_provider_summary_health_defaults_unobserved_enabled_keys_to_one() {
|
||||||
|
let endpoint = sample_endpoint(
|
||||||
|
"endpoint-chat",
|
||||||
|
"provider-openai",
|
||||||
|
"openai:chat",
|
||||||
|
"https://api.openai.example",
|
||||||
|
);
|
||||||
|
for health in [
|
||||||
|
None,
|
||||||
|
Some(json!({})),
|
||||||
|
Some(json!({"openai:chat": {"consecutive_failures": 0}})),
|
||||||
|
Some(json!({"openai:chat": {"health_score": null}})),
|
||||||
|
Some(json!({"openai:chat": {"health_score": "invalid"}})),
|
||||||
|
Some(json!({"openai:responses": {"health_score": 0.0}})),
|
||||||
|
] {
|
||||||
|
let key = sample_key("key-unobserved", "provider-openai", "openai:chat", "test")
|
||||||
|
.with_health_fields(health, None);
|
||||||
|
let payload = provider_health_summary(std::slice::from_ref(&endpoint), &[key]).await;
|
||||||
|
|
||||||
|
assert_eq!(payload["endpoint_health_details"][0]["health_score"], 1.0);
|
||||||
|
assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 1);
|
||||||
|
assert_eq!(payload["avg_health_score"], 1.0);
|
||||||
|
assert_eq!(payload["unhealthy_endpoints"], 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_provider_summary_health_is_unknown_without_enabled_keys() {
|
||||||
let endpoint = sample_endpoint(
|
let endpoint = sample_endpoint(
|
||||||
"endpoint-chat",
|
"endpoint-chat",
|
||||||
"provider-openai",
|
"provider-openai",
|
||||||
@@ -207,16 +240,7 @@ async fn admin_provider_summary_health_is_unknown_without_active_observations()
|
|||||||
let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test")
|
let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test")
|
||||||
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None);
|
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None);
|
||||||
disabled_key.is_active = false;
|
disabled_key.is_active = false;
|
||||||
for keys in [
|
for keys in [Vec::new(), vec![disabled_key]] {
|
||||||
Vec::new(),
|
|
||||||
vec![sample_key(
|
|
||||||
"key-unobserved",
|
|
||||||
"provider-openai",
|
|
||||||
"openai:chat",
|
|
||||||
"test",
|
|
||||||
)],
|
|
||||||
vec![disabled_key],
|
|
||||||
] {
|
|
||||||
let payload = provider_health_summary(std::slice::from_ref(&endpoint), &keys).await;
|
let payload = provider_health_summary(std::slice::from_ref(&endpoint), &keys).await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -233,7 +257,7 @@ async fn admin_provider_summary_health_is_unknown_without_active_observations()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoints() {
|
async fn admin_provider_summary_health_excludes_disabled_endpoints_and_defaults_unobserved_ones() {
|
||||||
let mut disabled_endpoint = sample_endpoint(
|
let mut disabled_endpoint = sample_endpoint(
|
||||||
"endpoint-disabled",
|
"endpoint-disabled",
|
||||||
"provider-openai",
|
"provider-openai",
|
||||||
@@ -279,16 +303,22 @@ async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoint
|
|||||||
|
|
||||||
let payload = provider_health_summary(&endpoints, &keys).await;
|
let payload = provider_health_summary(&endpoints, &keys).await;
|
||||||
|
|
||||||
assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.8);
|
let details = payload["endpoint_health_details"]
|
||||||
assert_eq!(
|
.as_array()
|
||||||
payload["endpoint_health_details"][1]["health_score"],
|
.expect("endpoint health details should be an array");
|
||||||
json!(null)
|
for (api_format, health_score, is_active) in [
|
||||||
);
|
("openai:chat", json!(0.8), true),
|
||||||
assert_eq!(
|
("openai:responses", json!(null), false),
|
||||||
payload["endpoint_health_details"][2]["health_score"],
|
("openai:embedding", json!(1.0), true),
|
||||||
json!(null)
|
] {
|
||||||
);
|
let detail = details
|
||||||
assert_eq!(payload["avg_health_score"], 0.8);
|
.iter()
|
||||||
|
.find(|detail| detail["api_format"] == api_format)
|
||||||
|
.expect("endpoint health detail should exist");
|
||||||
|
assert_eq!(detail["health_score"], health_score, "{api_format}");
|
||||||
|
assert_eq!(detail["is_active"], is_active, "{api_format}");
|
||||||
|
}
|
||||||
|
assert_eq!(payload["avg_health_score"], 0.9);
|
||||||
assert_eq!(payload["unhealthy_endpoints"], 0);
|
assert_eq!(payload["unhealthy_endpoints"], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
use super::*;
|
||||||
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
|
ProviderCatalogKeyListQuery, StoredProviderCatalogKeyMaintenanceSummary,
|
||||||
|
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats,
|
||||||
|
};
|
||||||
|
use aether_data_contracts::DataLayerError;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum FailedRead {
|
||||||
|
Providers,
|
||||||
|
Endpoints,
|
||||||
|
Keys,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailingSummaryRepository {
|
||||||
|
inner: InMemoryProviderCatalogReadRepository,
|
||||||
|
failed_read: FailedRead,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailingSummaryRepository {
|
||||||
|
fn check(&self, operation: FailedRead) -> Result<(), DataLayerError> {
|
||||||
|
if self.failed_read == operation {
|
||||||
|
return Err(DataLayerError::InvalidConfiguration(
|
||||||
|
"injected summary read failure".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProviderCatalogReadRepository for FailingSummaryRepository {
|
||||||
|
async fn list_providers(
|
||||||
|
&self,
|
||||||
|
active_only: bool,
|
||||||
|
) -> Result<Vec<StoredProviderCatalogProvider>, DataLayerError> {
|
||||||
|
self.check(FailedRead::Providers)?;
|
||||||
|
self.inner.list_providers(active_only).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_providers_by_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogProvider>, DataLayerError> {
|
||||||
|
self.check(FailedRead::Providers)?;
|
||||||
|
self.inner.list_providers_by_ids(provider_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_endpoints_by_ids(
|
||||||
|
&self,
|
||||||
|
endpoint_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
|
||||||
|
self.inner.list_endpoints_by_ids(endpoint_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_endpoints_by_provider_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
|
||||||
|
self.check(FailedRead::Endpoints)?;
|
||||||
|
self.inner
|
||||||
|
.list_endpoints_by_provider_ids(provider_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys_by_ids(
|
||||||
|
&self,
|
||||||
|
key_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||||
|
self.inner.list_keys_by_ids(key_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys_by_provider_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||||
|
self.inner.list_keys_by_provider_ids(provider_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_key_summaries_by_provider_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||||
|
self.check(FailedRead::Keys)?;
|
||||||
|
self.inner
|
||||||
|
.list_key_summaries_by_provider_ids(provider_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_key_maintenance_summaries_by_provider_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogKeyMaintenanceSummary>, DataLayerError> {
|
||||||
|
self.inner
|
||||||
|
.list_key_maintenance_summaries_by_provider_ids(provider_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys_page(
|
||||||
|
&self,
|
||||||
|
query: &ProviderCatalogKeyListQuery,
|
||||||
|
) -> Result<StoredProviderCatalogKeyPage, DataLayerError> {
|
||||||
|
self.inner.list_keys_page(query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_key_stats_by_provider_ids(
|
||||||
|
&self,
|
||||||
|
provider_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredProviderCatalogKeyStats>, DataLayerError> {
|
||||||
|
self.inner
|
||||||
|
.list_key_stats_by_provider_ids(provider_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_provider_summary_health_read_errors_do_not_look_like_empty_accounts() {
|
||||||
|
for failed_read in [
|
||||||
|
FailedRead::Providers,
|
||||||
|
FailedRead::Endpoints,
|
||||||
|
FailedRead::Keys,
|
||||||
|
] {
|
||||||
|
let repository = Arc::new(FailingSummaryRepository {
|
||||||
|
inner: InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_provider("provider-openai", "openai", 10)],
|
||||||
|
vec![sample_endpoint(
|
||||||
|
"endpoint-chat",
|
||||||
|
"provider-openai",
|
||||||
|
"openai:chat",
|
||||||
|
"https://api.openai.example",
|
||||||
|
)],
|
||||||
|
vec![
|
||||||
|
sample_key("key-observed", "provider-openai", "openai:chat", "test")
|
||||||
|
.with_health_fields(
|
||||||
|
Some(json!({"openai:chat": {"health_score": 1.0}})),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
failed_read,
|
||||||
|
});
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
for uri in [
|
||||||
|
"/api/admin/providers/summary",
|
||||||
|
"/api/admin/providers/summary?api_format=openai%3Achat",
|
||||||
|
"/api/admin/providers/provider-openai/summary",
|
||||||
|
] {
|
||||||
|
let response =
|
||||||
|
local_admin_providers_response(&state, http::Method::GET, uri, None).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"{failed_read:?}: {uri}"
|
||||||
|
);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.expect("error body should read");
|
||||||
|
let payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(&body).expect("error should parse");
|
||||||
|
assert_eq!(payload["detail"], ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL);
|
||||||
|
assert!(payload.get("items").is_none());
|
||||||
|
assert!(payload.get("endpoint_health_details").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_provider_summary_health_missing_provider_remains_not_found() {
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = local_admin_providers_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/providers/provider-missing/summary",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_provider_summary_health_counts_inherited_reverse_proxy_accounts() {
|
||||||
|
for (provider_type, auth_type, api_format) in [
|
||||||
|
("codex", "oauth", "openai:responses"),
|
||||||
|
("kiro", "oauth", "claude:messages"),
|
||||||
|
("kiro", "bearer", "claude:messages"),
|
||||||
|
("gemini_cli", "oauth", "gemini:generate_content"),
|
||||||
|
("antigravity", "oauth", "gemini:generate_content"),
|
||||||
|
("vertex_ai", "service_account", "gemini:generate_content"),
|
||||||
|
("chatgpt_web", "oauth", "openai:chat"),
|
||||||
|
("chatgpt_web", "bearer", "openai:chat"),
|
||||||
|
("windsurf", "oauth", "openai:chat"),
|
||||||
|
] {
|
||||||
|
for configured_formats in [None, Some(json!([]))] {
|
||||||
|
for score in [None, Some(0.0), Some(0.75)] {
|
||||||
|
let mut provider = sample_provider("provider-reverse", provider_type, 10);
|
||||||
|
provider.provider_type = provider_type.to_string();
|
||||||
|
let endpoint = sample_endpoint(
|
||||||
|
"endpoint-reverse",
|
||||||
|
&provider.id,
|
||||||
|
api_format,
|
||||||
|
"https://reverse.example",
|
||||||
|
);
|
||||||
|
let mut key = sample_key("key-reverse", &provider.id, api_format, "test");
|
||||||
|
key.auth_type = auth_type.to_string();
|
||||||
|
key.api_formats = configured_formats.clone();
|
||||||
|
key.encrypted_api_key = Some("summary".to_string());
|
||||||
|
key.encrypted_auth_config = Some("{}".to_string());
|
||||||
|
key.health_by_format =
|
||||||
|
score.map(|score| json!({api_format: {"health_score": score}}));
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![endpoint],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_reader_for_tests(repository),
|
||||||
|
);
|
||||||
|
|
||||||
|
for uri in [
|
||||||
|
"/api/admin/providers/summary",
|
||||||
|
"/api/admin/providers/provider-reverse/summary",
|
||||||
|
] {
|
||||||
|
let response =
|
||||||
|
local_admin_providers_response(&state, http::Method::GET, uri, None).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{provider_type}/{auth_type}: {uri}"
|
||||||
|
);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.expect("summary body should read");
|
||||||
|
let payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(&body).expect("summary should parse");
|
||||||
|
let summary = payload
|
||||||
|
.get("items")
|
||||||
|
.map(|items| &items[0])
|
||||||
|
.unwrap_or(&payload);
|
||||||
|
let detail = &summary["endpoint_health_details"][0];
|
||||||
|
assert_eq!(detail["total_keys"], 1, "{provider_type}/{auth_type}");
|
||||||
|
assert_eq!(detail["active_keys"], 1, "{provider_type}/{auth_type}");
|
||||||
|
assert_eq!(
|
||||||
|
detail["health_score"],
|
||||||
|
json!(score.unwrap_or(1.0)),
|
||||||
|
"{provider_type}/{auth_type}"
|
||||||
|
);
|
||||||
|
assert_eq!(summary["avg_health_score"], json!(score.unwrap_or(1.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,31 +7,47 @@
|
|||||||
|
|
||||||
## 统计规则
|
## 统计规则
|
||||||
|
|
||||||
|
沿用 `v0.7.13`(`535ee098c`)的默认健康规则:已启用密钥缺少该格式的健康记录时,
|
||||||
|
按 `1.0` 参与统计,而不是要求先有一次请求或探测才能显示健康。
|
||||||
|
|
||||||
- 仅统计启用端点下、支持该端点 API 格式的启用密钥。
|
- 仅统计启用端点下、支持该端点 API 格式的启用密钥。
|
||||||
- 每个密钥读取其 `health_by_format[api_format].health_score`,不跨格式借用分数。
|
- 每个密钥读取其 `health_by_format[api_format].health_score`,不跨格式借用分数。
|
||||||
- 只对已有的有效健康分数求算术平均;没有健康数据的密钥不按满分计入。
|
- 对上述启用密钥求算术平均;缺少有效分数的密钥沿用旧版默认值 `1.0`。
|
||||||
- 分数范围为 `0` 到 `1`,沿用调度器的分数读取与范围约束。
|
- 分数范围为 `0` 到 `1`,沿用调度器的分数读取与范围约束。
|
||||||
- 停用端点、没有启用密钥或没有有效健康分数时,端点的 `health_score` 返回 `null`。
|
- 停用端点或没有启用密钥时,端点的 `health_score` 返回 `null`。
|
||||||
- `avg_health_score` 仅平均有有效健康数据的启用端点;没有此类端点时返回 `null`。
|
- `avg_health_score` 仅平均有启用密钥的启用端点,包含按默认值计算的端点;没有此类端点时返回 `null`。
|
||||||
- `unhealthy_endpoints` 仅统计上述端点中健康度低于 `0.5` 的数量,不把未知状态算作故障。
|
- `unhealthy_endpoints` 仅统计上述端点中健康度低于 `0.5` 的数量,不把未知状态算作故障。
|
||||||
|
|
||||||
`total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。
|
`total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。
|
||||||
|
|
||||||
|
Codex、Kiro、Gemini CLI、Antigravity 等固定提供商的账号按照各提供商的认证规则,
|
||||||
|
继承其启用端点的 API 格式;账号的 `api_formats` 为 `null` 或空数组,不代表没有配置账号。
|
||||||
|
继承格式决定账号归属;缺少对应格式的分数时同样使用默认值 `1.0`,不借用其他格式的异常分数。
|
||||||
|
|
||||||
## 数据读取
|
## 数据读取
|
||||||
|
|
||||||
摘要从密钥的轻量投影读取 API 格式、启用状态和 `health_by_format`。
|
摘要从密钥的轻量投影读取 API 格式、启用状态和 `health_by_format`。
|
||||||
PostgreSQL 投影中的凭据字段使用 `summary` / `{}` 等脱敏占位值,并非真实密文,
|
PostgreSQL 投影中的凭据字段使用 `summary` / `{}` 等脱敏占位值,并非真实密文,
|
||||||
因此摘要读取不执行凭据解密、认证或迁移。完整密钥读取仍保留原有的凭据安全校验。
|
因此摘要读取不执行凭据解密、认证或迁移。完整密钥读取仍保留原有的凭据安全校验。
|
||||||
|
|
||||||
若将这些占位值送入凭据校验,读取会失败,并被摘要聚合当作空密钥列表,
|
若将这些占位值送入凭据校验,读取会失败,旧的摘要聚合还会将其当作空密钥列表,
|
||||||
导致已配置密钥的端点也被错误显示为灰色;不能通过给缺失分数默认填 `100%` 来修复。
|
导致已配置密钥的端点也被错误显示为灰色。默认健康分数只能用于成功读取的启用密钥,
|
||||||
|
不能用于掩盖查询或凭据投影错误。
|
||||||
|
|
||||||
|
提供商、端点或密钥摘要查询失败时,接口返回 `503`,不能将失败当作空列表并返回零账号。
|
||||||
|
单个提供商确实不存在时仍返回 `404`。页面刷新失败保留已有列表,并显示加载错误。
|
||||||
|
|
||||||
## 页面展示
|
## 页面展示
|
||||||
|
|
||||||
桌面表格和手机卡片使用相同规则:
|
桌面表格、网格卡片和手机卡片使用相同规则:
|
||||||
|
|
||||||
- 有健康数据时显示百分比;有效的零分显示 `0%`。
|
- 有健康数据时显示百分比;有效的零分显示 `0%`。
|
||||||
- 无健康数据时显示灰色占位条和 `-`,提示“暂无健康数据”,不显示 `100%` 或 `0%`。
|
- 有启用密钥、但这些密钥尚无该格式的健康记录时,显示绿色 `100%`,与 `v0.7.13` 一致。
|
||||||
- 端点停用、未配置密钥或没有启用密钥时显示灰色占位条和对应状态提示。
|
- 端点停用、未配置密钥或没有启用密钥时显示灰色占位条和对应状态提示。
|
||||||
|
|
||||||
此分数是密钥当前健康状态的聚合,不是某个时间窗口内的请求成功率,也不是主动探测结果。
|
账号详情保留原有默认 `100%` 的规则。已有观测仍取各格式最低分;端点分数则只聚合对应格式,
|
||||||
|
两者统计范围不同,不要求百分比完全相等。调度器原有的缺省健康策略不变。
|
||||||
|
|
||||||
|
此分数是密钥当前健康状态的聚合,包含默认健康值,不是某个时间窗口内的请求成功率,
|
||||||
|
也不表示已经执行过主动探测。相比 `v0.7.13`,仍保留停用账号/端点不参与健康聚合、
|
||||||
|
凭据脱敏与查询失败显式报错等修复,不整体回退旧版代码。
|
||||||
|
|||||||
@@ -106,6 +106,29 @@ function mount(props: InstanceType<typeof ProviderKeyActionCluster>['$props']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ProviderKeyActionCluster', () => {
|
describe('ProviderKeyActionCluster', () => {
|
||||||
|
it.each([
|
||||||
|
{ score: 0, label: '0%' },
|
||||||
|
{ score: 1, label: '100%' },
|
||||||
|
])('renders the legacy health score $score as $label', ({ score, label }) => {
|
||||||
|
const { root, unmount } = mount({
|
||||||
|
apiKey: createProviderKey({ health_score: score }),
|
||||||
|
providerType: 'codex',
|
||||||
|
recoverable: false,
|
||||||
|
recoverTitle: '',
|
||||||
|
circuitBreakerTitle: '',
|
||||||
|
circuitProbeCountdown: '',
|
||||||
|
healthScoreBarClass: 'bg-red-500',
|
||||||
|
healthScoreTextClass: 'text-red-600',
|
||||||
|
proxyPopoverOpen: false,
|
||||||
|
proxyNodeName: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const health = root.querySelector('[data-testid="provider-key-health"]')
|
||||||
|
expect(health?.textContent?.trim()).toBe(label)
|
||||||
|
expect(health?.textContent).not.toContain('待观测')
|
||||||
|
unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders circuit, health, proxy and antigravity actions', () => {
|
it('renders circuit, health, proxy and antigravity actions', () => {
|
||||||
const { root, unmount } = mount({
|
const { root, unmount } = mount({
|
||||||
apiKey: createProviderKey({
|
apiKey: createProviderKey({
|
||||||
|
|||||||
+17
-2
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApp, type App, type Component } from 'vue'
|
import { createApp, type App, type Component } from 'vue'
|
||||||
|
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { EndpointHealthDetail, ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
import { createI18n } from '@/i18n'
|
import { createI18n } from '@/i18n'
|
||||||
import ProviderTableRow from '../ProviderTableRow.vue'
|
import ProviderTableRow from '../ProviderTableRow.vue'
|
||||||
import ProviderMobileCard from '../ProviderMobileCard.vue'
|
import ProviderMobileCard from '../ProviderMobileCard.vue'
|
||||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function mountProvider(component: Component, healthScore: number | null) {
|
function mountProvider(component: Component, healthScore: number | null, overrides: Partial<EndpointHealthDetail> = {}) {
|
||||||
const provider: ProviderWithEndpointsSummary = {
|
const provider: ProviderWithEndpointsSummary = {
|
||||||
id: 'provider-1',
|
id: 'provider-1',
|
||||||
name: 'Provider One',
|
name: 'Provider One',
|
||||||
@@ -45,6 +45,7 @@ function mountProvider(component: Component, healthScore: number | null) {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
total_keys: 1,
|
total_keys: 1,
|
||||||
active_keys: 1,
|
active_keys: 1,
|
||||||
|
...overrides,
|
||||||
}],
|
}],
|
||||||
ops_configured: false,
|
ops_configured: false,
|
||||||
created_at: '2026-09-06T00:00:00Z',
|
created_at: '2026-09-06T00:00:00Z',
|
||||||
@@ -81,6 +82,7 @@ describe.each([
|
|||||||
{ score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' },
|
{ score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' },
|
||||||
{ score: 0, label: '0%', width: '5%', color: 'bg-red-500' },
|
{ score: 0, label: '0%', width: '5%', color: 'bg-red-500' },
|
||||||
{ score: 0.8, label: '80%', width: '80%', color: 'bg-green-500' },
|
{ score: 0.8, label: '80%', width: '80%', color: 'bg-green-500' },
|
||||||
|
{ score: 1, label: '100%', width: '100%', color: 'bg-green-500' },
|
||||||
])('renders $score without confusing unknown health with zero', ({ score, label, width, color }) => {
|
])('renders $score without confusing unknown health with zero', ({ score, label, width, color }) => {
|
||||||
const root = mountProvider(component, score)
|
const root = mountProvider(component, score)
|
||||||
const health = root.querySelector('[title*="健康"]')
|
const health = root.querySelector('[title*="健康"]')
|
||||||
@@ -90,4 +92,17 @@ describe.each([
|
|||||||
expect(bar?.style.width).toBe(width)
|
expect(bar?.style.width).toBe(width)
|
||||||
expect(bar?.classList.contains(color)).toBe(true)
|
expect(bar?.classList.contains(color)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ overrides: { is_active: false }, tooltip: '端点禁用' },
|
||||||
|
{ overrides: { active_keys: 0, total_keys: 0 }, tooltip: '未配置密钥' },
|
||||||
|
{ overrides: { active_keys: 0 }, tooltip: '无可用密钥' },
|
||||||
|
])('keeps $tooltip gray even when the health score defaults to one', ({ overrides, tooltip }) => {
|
||||||
|
const root = mountProvider(component, 1, overrides)
|
||||||
|
const health = root.querySelector(`[title*="${tooltip}"]`)
|
||||||
|
const bar = health?.querySelector<HTMLElement>('.transition-all')
|
||||||
|
|
||||||
|
expect(health?.querySelectorAll('span')[1]?.textContent?.trim()).toBe('-')
|
||||||
|
expect(bar?.classList.contains('bg-muted-foreground/40')).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
COMPOSE_FILE="${REPO_ROOT}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_ROOT}/docker-compose.yml"
|
||||||
RELEASE_WORKFLOW="${REPO_ROOT}/.github/workflows/release.yml"
|
RELEASE_WORKFLOW="${REPO_ROOT}/.github/workflows/release.yml"
|
||||||
|
NIGHTLY_WORKFLOW="${REPO_ROOT}/.github/workflows/nightly.yml"
|
||||||
TUNNEL_RELEASE_WORKFLOW="${REPO_ROOT}/.github/workflows/build-tunnel.yml"
|
TUNNEL_RELEASE_WORKFLOW="${REPO_ROOT}/.github/workflows/build-tunnel.yml"
|
||||||
APP_DOCKERFILE="${REPO_ROOT}/Dockerfile.app"
|
APP_DOCKERFILE="${REPO_ROOT}/Dockerfile.app"
|
||||||
|
|
||||||
@@ -48,6 +49,27 @@ assert_line "${RELEASE_WORKFLOW}" " release-assets/install.sh"
|
|||||||
assert_line "${RELEASE_WORKFLOW}" " release-assets/SHA256SUMS"
|
assert_line "${RELEASE_WORKFLOW}" " release-assets/SHA256SUMS"
|
||||||
assert_line "${RELEASE_WORKFLOW}" " release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json"
|
assert_line "${RELEASE_WORKFLOW}" " release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json"
|
||||||
|
|
||||||
|
for workflow in "${RELEASE_WORKFLOW}" "${NIGHTLY_WORKFLOW}"; do
|
||||||
|
assert_line "${workflow}" " - name: linux-amd64"
|
||||||
|
assert_line "${workflow}" " - name: linux-arm64"
|
||||||
|
assert_line "${workflow}" " for arch in amd64 arm64; do"
|
||||||
|
assert_line "${workflow}" ' bundle="aether-${VERSION}-linux-${arch}"'
|
||||||
|
if grep -Eq 'macos|apple-darwin|for platform in' "${workflow}"; then
|
||||||
|
fail_test "gateway workflow still references a removed build platform: ${workflow}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" \
|
||||||
|
' test "$(find release-assets -maxdepth 1 -name '\''*.tar.gz'\'' | wc -l)" -eq 2'
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" \
|
||||||
|
' test "$(wc -l < release-assets/SHA256SUMS)" -eq 2'
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" " aether-nightly-linux-amd64.tar.gz"
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" " aether-nightly-linux-arm64.tar.gz"
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" \
|
||||||
|
' if [[ "${asset_name}" == aether-nightly-*.tar.gz && ! -f "release-assets/${asset_name}" ]]; then'
|
||||||
|
assert_line "${NIGHTLY_WORKFLOW}" \
|
||||||
|
' gh release delete-asset "${RELEASE_TAG}" "${asset_name}" \'
|
||||||
|
|
||||||
assert_line "${TUNNEL_RELEASE_WORKFLOW}" " attestations: write"
|
assert_line "${TUNNEL_RELEASE_WORKFLOW}" " attestations: write"
|
||||||
assert_line "${TUNNEL_RELEASE_WORKFLOW}" " id-token: write"
|
assert_line "${TUNNEL_RELEASE_WORKFLOW}" " id-token: write"
|
||||||
assert_line "${TUNNEL_RELEASE_WORKFLOW}" \
|
assert_line "${TUNNEL_RELEASE_WORKFLOW}" \
|
||||||
@@ -63,4 +85,4 @@ if grep -ERq '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]+[^[:space:]#]+@[^0-9
|
|||||||
fail_test "workflow contains a mutable third-party action reference"
|
fail_test "workflow contains a mutable third-party action reference"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "PASS: release supply-chain pins and provenance workflow"
|
echo "PASS: release supply-chain pins, provenance and Linux-only gateway platforms"
|
||||||
|
|||||||
Reference in New Issue
Block a user