mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d29ddfffc | ||
|
|
45903b684b | ||
|
|
a580874697 | ||
|
|
885369f19f | ||
|
|
24ff339c97 | ||
|
|
e582eb73d2 | ||
|
|
ec252b3b46 | ||
|
|
6503f36750 | ||
|
|
2366f66f33 | ||
|
|
cc0303c65e | ||
|
|
b11eeac29e | ||
|
|
ad5f3ba997 | ||
|
|
15455ca619 | ||
|
|
904315c209 | ||
|
|
95d720dd86 | ||
|
|
74f59cc4f8 | ||
|
|
e979454457 |
@@ -29,6 +29,7 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
docker_version: ${{ steps.version.outputs.docker_version }}
|
||||
build_time: ${{ steps.version.outputs.build_time }}
|
||||
git_sha: ${{ steps.version.outputs.git_sha }}
|
||||
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
|
||||
end
|
||||
archive = platform["archive"]
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz or zip") unless ["tar.gz", "zip"].include?(archive)
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
|
||||
platform_key = "#{platform["os"]}-#{platform["arch"]}"
|
||||
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
|
||||
seen_platforms[platform_key] = true
|
||||
@@ -146,6 +147,8 @@ jobs:
|
||||
|
||||
puts "tag=#{release_tag}"
|
||||
puts "version=#{version}"
|
||||
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
|
||||
puts "docker_version=#{version.tr("+", "-")}"
|
||||
puts "build_time=#{Time.now.utc.iso8601}"
|
||||
puts "git_sha=#{git_sha.strip}"
|
||||
puts "platform_matrix=#{matrix}"
|
||||
@@ -321,7 +324,6 @@ jobs:
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
|
||||
@@ -357,8 +359,7 @@ jobs:
|
||||
|
||||
binary_dest = package_root / platform["binary"]
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
if platform["os"] != "windows":
|
||||
binary_dest.chmod(0o755)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
@@ -366,11 +367,6 @@ jobs:
|
||||
if platform["archive"] == "tar.gz":
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
elif platform["archive"] == "zip":
|
||||
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(package_root.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(package_dir))
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
@@ -539,7 +535,7 @@ jobs:
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
@@ -562,7 +558,7 @@ jobs:
|
||||
fi
|
||||
|
||||
target_args=(
|
||||
-t "${image}:${VERSION}"
|
||||
-t "${image}:${DOCKER_VERSION}"
|
||||
-t "${image}:sha-${GIT_SHA}"
|
||||
)
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
@@ -570,7 +566,7 @@ jobs:
|
||||
fi
|
||||
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
|
||||
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${VERSION}")"
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
|
||||
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
|
||||
for platform in "${platforms[@]}"; do
|
||||
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
|
||||
@@ -580,7 +576,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
digest="$(docker buildx imagetools inspect "${image}:${VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
if [ -z "${digest}" ]; then
|
||||
echo "missing image digest"
|
||||
exit 1
|
||||
@@ -598,7 +594,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
run: |
|
||||
@@ -609,7 +605,7 @@ jobs:
|
||||
fi
|
||||
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
tags+=("${image}:latest")
|
||||
fi
|
||||
@@ -637,7 +633,7 @@ jobs:
|
||||
**Docker:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
"crates/providers/xai",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn workspace_member_list_matches_the_frozen_dag_scope() {
|
||||
let manifest =
|
||||
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
assert!(
|
||||
manifest.contains(&format!("\"{member}\"")),
|
||||
"workspace must include {member}"
|
||||
);
|
||||
}
|
||||
let member_count = manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
member_count,
|
||||
WORKSPACE_MEMBERS.len(),
|
||||
"new workspace members must extend the dependency DAG rules"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-core",
|
||||
&[
|
||||
"axum",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"sqlx",
|
||||
"redis",
|
||||
"gateway-host",
|
||||
"gateway-store",
|
||||
"provider-openai",
|
||||
"provider-xai",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_protocol_has_no_workspace_dependencies() {
|
||||
for name in dependency_names("crates/gateway-protocol") {
|
||||
assert!(
|
||||
!name.starts_with("gateway-") && !name.starts_with("provider-"),
|
||||
"gateway-protocol must not depend on workspace crate `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_crates_do_not_depend_on_each_other() {
|
||||
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
|
||||
assert_no_dependency("crates/providers/xai", &["provider-openai"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_admin_stays_free_of_infrastructure_dependencies() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-admin",
|
||||
&["axum", "sqlx", "redis", "reqwest"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let src = backend_root().join(member).join("src");
|
||||
let sources = super::rust_files(&src);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = src.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("backend workspace root")
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
let mut in_dependencies = false;
|
||||
let mut names = Vec::new();
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_dependencies = line == "[dependencies]";
|
||||
continue;
|
||||
}
|
||||
if !in_dependencies || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, _)) = line.split_once('=') {
|
||||
names.push(name.trim().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
|
||||
for name in dependency_names(member) {
|
||||
assert!(
|
||||
!forbidden.contains(&name.as_str()),
|
||||
"{member} must not depend on `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
use std::{
|
||||
@@ -21,7 +22,11 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
);
|
||||
assert_eq!(
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([PathBuf::from("bootstrap.rs"), PathBuf::from("main.rs")]),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ pub struct AccountSummary {
|
||||
/// 账号启停写入命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -197,7 +196,6 @@ pub struct SetAccountEnabled {
|
||||
/// 账号批量删除命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
@@ -151,7 +150,6 @@ pub struct CreateClientKey {
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -175,7 +173,6 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -186,7 +183,6 @@ pub struct UpdateClientKey {
|
||||
/// 修改 Client Key 启用状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -194,7 +190,6 @@ pub struct SetClientKeyEnabled {
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
|
||||
use std::{num::NonZeroU32, str::FromStr};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
|
||||
|
||||
use super::{AdminModelError, PageSize};
|
||||
|
||||
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
|
||||
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
|
||||
#[must_use]
|
||||
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeRange {
|
||||
|
||||
@@ -101,7 +101,6 @@ pub struct CredentialDetails {
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -110,7 +109,6 @@ impl fmt::Debug for ImportCredentials {
|
||||
formatter
|
||||
.debug_struct("ImportCredentials")
|
||||
.field("context", &self.context)
|
||||
.field("expected_config_revision", &self.expected_config_revision)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
@@ -169,7 +167,6 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
@@ -254,7 +251,6 @@ pub enum AuthorizationMutationTarget {
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
@@ -263,24 +259,17 @@ pub struct PendingAuthorizationMutation {
|
||||
impl PendingAuthorizationMutation {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expected_config_revision(&self) -> Revision {
|
||||
self.expected_config_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
@@ -301,7 +290,6 @@ impl PendingAuthorizationMutation {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ReauthorizationTarget>,
|
||||
}
|
||||
@@ -406,7 +394,6 @@ impl fmt::Debug for CompleteAuthorization {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
@@ -422,7 +409,6 @@ pub struct CredentialMutationResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
@@ -520,7 +506,6 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ pub struct RuntimeSettings {
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub expected_config_revision: Revision,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
|
||||
@@ -179,7 +179,7 @@ pub trait AuthStore: Send + Sync {
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理及其 revision CAS 写入。
|
||||
/// Client API Key 管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
@@ -265,7 +265,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 的 revision CAS 写入。
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
|
||||
@@ -280,14 +280,12 @@ pub trait SettingsStore: Send + Sync {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use gateway_core::{
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
AdminError, MutationContext,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountRecord, AccountStatus, AccountUsageWindowQuery,
|
||||
@@ -54,7 +54,6 @@ pub trait AccountsService: Send + Sync {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError>;
|
||||
|
||||
@@ -354,7 +353,6 @@ impl AccountsService for DefaultAccountsService {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(&account_id).await?;
|
||||
@@ -367,7 +365,6 @@ impl AccountsService for DefaultAccountsService {
|
||||
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
|
||||
let result = commit_credential_refresh(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
context,
|
||||
"provider credential refresh",
|
||||
@@ -474,7 +471,8 @@ fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> Accoun
|
||||
crate::model::accounts::AccountAvailability::QuotaExhausted => {
|
||||
AccountStatus::QuotaExhausted
|
||||
}
|
||||
crate::model::accounts::AccountAvailability::Expired => AccountStatus::Expired,
|
||||
crate::model::accounts::AccountAvailability::Expired
|
||||
| crate::model::accounts::AccountAvailability::Invalid => AccountStatus::Expired,
|
||||
_ if account
|
||||
.access_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now) =>
|
||||
|
||||
@@ -36,6 +36,10 @@ pub trait AuthService: Send + Sync {
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
|
||||
/// `Utc::now() + session_ttl` 永不越界。
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
|
||||
/// 单管理员认证策略的最终实现。
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
@@ -51,8 +55,8 @@ impl DefaultAuthService {
|
||||
store: Arc<dyn AuthStore>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(i64::MAX)
|
||||
.max(1);
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES);
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
session_ttl: Duration::minutes(minutes),
|
||||
|
||||
@@ -94,7 +94,6 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
|
||||
@@ -108,7 +108,6 @@ async fn pending_authorization(
|
||||
},
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
command.expected_config_revision,
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
@@ -215,20 +214,13 @@ async fn commit_authorization(
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -244,20 +236,13 @@ async fn commit_credential_rotation(
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -289,7 +274,6 @@ async fn set_credential_enabled(
|
||||
let revision = accounts
|
||||
.set_account_enabled(
|
||||
SetAccountEnabled {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
enabled,
|
||||
},
|
||||
@@ -320,7 +304,6 @@ async fn delete_credentials(
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
@@ -38,7 +38,6 @@ const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
|
||||
/// API 消费的观测控制面服务。
|
||||
#[async_trait]
|
||||
@@ -105,7 +104,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
self.store.dashboard_runtime_slots(range.end),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_billing(&mut observation.recent_requests)?;
|
||||
self.enrich_billing(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
@@ -177,7 +176,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_billing(&mut page.items)?;
|
||||
self.enrich_billing(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -190,7 +189,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.usage_record_detail(request_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage record"))?;
|
||||
self.enrich_billing(std::slice::from_mut(&mut detail.request))?;
|
||||
self.enrich_billing(std::slice::from_mut(&mut detail.request));
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -492,10 +491,9 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_billing(
|
||||
&self,
|
||||
records: &mut [crate::model::observability::UsageRecord],
|
||||
) -> Result<(), AdminError> {
|
||||
/// 逐条尽力把 calculated 总额升级为完整分解;单条脏数据(非法 Provider kind
|
||||
/// 或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
|
||||
for record in records {
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
@@ -509,8 +507,9 @@ impl DefaultObservabilityService {
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let provider_kind = ProviderKind::new(provider.to_owned())
|
||||
.map_err(|_| AdminError::internal("Stored Provider kind is invalid"))?;
|
||||
let Ok(provider_kind) = ProviderKind::new(provider.to_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: upstream_model_id.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
@@ -519,15 +518,10 @@ impl DefaultObservabilityService {
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Some(breakdown) = self
|
||||
.providers
|
||||
.calculated_billing(&provider_kind, &input)
|
||||
.map_err(|error| map_provider_error(error, "usage billing"))?
|
||||
{
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,11 +827,6 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
|
||||
@@ -112,7 +112,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -127,13 +126,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
@@ -200,7 +193,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let expected_config_revision = command.mutation.expected_config_revision;
|
||||
let account_id = command.mutation.account_id;
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
@@ -227,7 +219,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
|
||||
@@ -86,13 +86,12 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
|
||||
let mutation = self
|
||||
.store
|
||||
.replace_admin_api_key(settings.config_revision, key.clone(), context)
|
||||
.replace_admin_api_key(key.clone(), context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
@@ -103,10 +102,9 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.delete_admin_api_key(settings.config_revision, context)
|
||||
.delete_admin_api_key(context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
|
||||
@@ -89,7 +89,6 @@ impl XaiService for DefaultXaiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -104,13 +103,7 @@ impl XaiService for DefaultXaiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
|
||||
@@ -342,7 +342,6 @@ pub(super) struct FakeAccountStore {
|
||||
account: AccountRecord,
|
||||
fail_commit: Mutex<bool>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
authorization_revision: Mutex<Option<Revision>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
}
|
||||
|
||||
@@ -353,7 +352,6 @@ impl FakeAccountStore {
|
||||
account: account_record(kind),
|
||||
fail_commit: Mutex::new(false),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
authorization_revision: Mutex::new(None),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
@@ -366,13 +364,6 @@ impl FakeAccountStore {
|
||||
self.audit_requests.lock().expect("audit requests").clone()
|
||||
}
|
||||
|
||||
pub(super) fn authorization_revision(&self) -> Option<Revision> {
|
||||
*self
|
||||
.authorization_revision
|
||||
.lock()
|
||||
.expect("authorization revision")
|
||||
}
|
||||
|
||||
pub(super) fn set_quota_window_usage(&self, usage: Vec<AccountUsageWindowResult>) {
|
||||
*self.quota_window_usage.lock().expect("quota window usage") = usage;
|
||||
}
|
||||
@@ -519,10 +510,6 @@ impl AccountStore for FakeAccountStore {
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.record("store.commit_authorization");
|
||||
self.record_context(context);
|
||||
*self
|
||||
.authorization_revision
|
||||
.lock()
|
||||
.expect("authorization revision") = Some(command.pending.expected_config_revision());
|
||||
self.require_commit()?;
|
||||
let (account_id, credential_revision) = match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => (credential.account_id, None),
|
||||
@@ -627,7 +614,6 @@ impl SettingsStore for StaticSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -636,7 +622,6 @@ impl SettingsStore for StaticSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
@@ -769,7 +754,6 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-request"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
@@ -882,7 +866,6 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-provider-error"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
@@ -895,7 +878,7 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
|
||||
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
@@ -906,11 +889,10 @@ async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-store-error"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
.expect_err("Store CAS must fail");
|
||||
.expect_err("Store commit must fail");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
@@ -1039,7 +1021,7 @@ fn prepared_create_with_id(
|
||||
|
||||
fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResult {
|
||||
CredentialMutationResult {
|
||||
config_revision: revision(command.expected_config_revision.get() + 1),
|
||||
config_revision: revision(2),
|
||||
account_id: command.prepared.account_id,
|
||||
credential_revision: Some(revision(
|
||||
command.prepared.expected_credential_revision.get() + 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{collections::BTreeMap, sync::Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
@@ -120,3 +121,33 @@ async fn repeated_default_initialization_should_not_replace_password() {
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking() {
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.session_ttl_minutes(i64::MAX as u64)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let before = Utc::now();
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("login with huge session TTL");
|
||||
|
||||
assert!(result.expires_at > before);
|
||||
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ use gateway_core::{
|
||||
|
||||
pub(super) struct AdminHarness {
|
||||
default_password: String,
|
||||
session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
@@ -84,6 +85,7 @@ impl AdminHarness {
|
||||
let unavailable = Arc::new(UnavailableStore);
|
||||
Self {
|
||||
default_password: "strong-test-password".to_owned(),
|
||||
session_ttl_minutes: 60,
|
||||
accounts: unavailable.clone(),
|
||||
auth: Arc::new(BootstrapAuthStore::default()),
|
||||
client_keys: unavailable.clone(),
|
||||
@@ -103,6 +105,11 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn session_ttl_minutes(mut self, minutes: u64) -> Self {
|
||||
self.session_ttl_minutes = minutes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn accounts(mut self, store: Arc<dyn AccountStore>) -> Self {
|
||||
self.accounts = store;
|
||||
self
|
||||
@@ -148,7 +155,7 @@ impl AdminHarness {
|
||||
pub(super) async fn build(self) -> AdminServices {
|
||||
gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
session_ttl_minutes: 60,
|
||||
session_ttl_minutes: self.session_ttl_minutes,
|
||||
default_username: "admin".to_owned(),
|
||||
default_password: InitialAdminPassword::new(self.default_password),
|
||||
},
|
||||
@@ -442,7 +449,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -451,7 +457,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unavailable("admin API key"))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
num::NonZeroU32,
|
||||
str::FromStr as _,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
@@ -9,13 +10,14 @@ use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, TrendKind, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageOverview, UsagePage, UsageQuery,
|
||||
HealthStatus, OpsErrorPage, OpsErrorQuery, PageNumber, RequestMetricPoint,
|
||||
RequestMetrics, RequestOutcome, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageRecord, china_day_start,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -318,12 +320,46 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
assert_eq!(diagnostics.items[1].request_share, 0.25);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
let invalid_kind = "x".repeat(65);
|
||||
store.replace_usage_records(vec![
|
||||
calculated_total_record("request_invalid_kind", Some(&invalid_kind), now),
|
||||
calculated_total_record("request_unregistered_kind", Some("anthropic"), now),
|
||||
calculated_total_record("request_enriched", Some("openai"), now),
|
||||
]);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let page = services
|
||||
.observability()
|
||||
.usage_records(usage_query(now))
|
||||
.await
|
||||
.expect("one bad record must not fail the whole usage list");
|
||||
|
||||
assert_eq!(page.items.len(), 3);
|
||||
assert!(
|
||||
matches!(page.items[0].billing, Some(UsageBilling::Total { .. })),
|
||||
"invalid Provider kind keeps the stored total"
|
||||
);
|
||||
assert!(
|
||||
matches!(page.items[1].billing, Some(UsageBilling::Total { .. })),
|
||||
"unregistered Provider kind keeps the stored total"
|
||||
);
|
||||
assert!(
|
||||
matches!(page.items[2].billing, Some(UsageBilling::Calculated(_))),
|
||||
"healthy record is still enriched"
|
||||
);
|
||||
}
|
||||
|
||||
struct FixtureObservabilityStore {
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
usage_records: Mutex<Vec<UsageRecord>>,
|
||||
}
|
||||
|
||||
impl FixtureObservabilityStore {
|
||||
@@ -339,6 +375,7 @@ impl FixtureObservabilityStore {
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
usage_records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +401,10 @@ impl FixtureObservabilityStore {
|
||||
fn replace_runtime_slots(&self, runtime_slots: Option<DashboardRuntimeSlots>) {
|
||||
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
|
||||
}
|
||||
|
||||
fn replace_usage_records(&self, records: Vec<UsageRecord>) {
|
||||
*self.usage_records.lock().expect("usage records") = records;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -412,7 +453,13 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
Err(super::unavailable("usage records"))
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
|
||||
Ok(UsagePage {
|
||||
items,
|
||||
total,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
@@ -471,7 +518,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -480,7 +526,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
@@ -511,6 +556,96 @@ fn observation_range(end: DateTime<Utc>) -> TimeRange {
|
||||
TimeRange::new(end - Duration::hours(24), end).expect("observation range")
|
||||
}
|
||||
|
||||
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
cursor: None,
|
||||
page: PageNumber::new(NonZeroU32::new(1).expect("page number")),
|
||||
page_size: PageSize::new(50).expect("page size"),
|
||||
}
|
||||
}
|
||||
|
||||
fn calculated_total_record(
|
||||
id: &str,
|
||||
provider_kind: Option<&str>,
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageRecord {
|
||||
UsageRecord {
|
||||
id: id.to_owned(),
|
||||
client_api_key_ref: "key_billing".to_owned(),
|
||||
config_revision: 1,
|
||||
protocol: "openai".to_owned(),
|
||||
operation: "generate".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "gpt-5.5".to_owned(),
|
||||
provider_kind: provider_kind.map(str::to_owned),
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
http_version: None,
|
||||
websocket_pool: None,
|
||||
provider_metadata_json: None,
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: None,
|
||||
outcome: RequestOutcome::Succeeded,
|
||||
client_status_code: Some(200),
|
||||
upstream_status_code: Some(200),
|
||||
client_response_id: None,
|
||||
upstream_request_id: None,
|
||||
upstream_response_id: None,
|
||||
error_kind: None,
|
||||
provider_error_code: None,
|
||||
error_message: None,
|
||||
retry_after_ms: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: Some(1_000),
|
||||
cost_source: "calculated".to_owned(),
|
||||
cost_amount: None,
|
||||
cost_currency: None,
|
||||
billing: Some(UsageBilling::Total {
|
||||
source: "calculated".to_owned(),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
|
||||
.expect("billing total"),
|
||||
},
|
||||
}),
|
||||
transport_decision_wait_ms: None,
|
||||
connect_ms: None,
|
||||
headers_ms: None,
|
||||
first_event_ms: None,
|
||||
first_reasoning_ms: None,
|
||||
first_text_ms: None,
|
||||
first_token_ms: None,
|
||||
provider_processing_ms: None,
|
||||
latency_ms: Some(100),
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_preset: None,
|
||||
request_kind: None,
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: false,
|
||||
image_generation_succeeded: None,
|
||||
started_at: now,
|
||||
deadline_at: now,
|
||||
completed_at: Some(now),
|
||||
}
|
||||
}
|
||||
|
||||
fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) -> RequestMetricPoint {
|
||||
RequestMetricPoint {
|
||||
bucket_start,
|
||||
@@ -535,11 +670,6 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
}
|
||||
}
|
||||
|
||||
fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + 8 * 60 * 60).rem_euclid(24 * 60 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value.timestamp().rem_euclid(15 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
|
||||
@@ -114,7 +114,6 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -151,7 +150,6 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -193,7 +191,6 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -218,7 +215,6 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-error"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -237,7 +233,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai"),
|
||||
expected_config_revision: revision(7),
|
||||
name: "reauthorize".to_owned(),
|
||||
reauthorization: Some(ReauthorizationTarget {
|
||||
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
@@ -248,7 +243,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.expect("start reauthorization");
|
||||
|
||||
let pending = provider.pending().expect("pending envelope");
|
||||
assert_eq!(pending.expected_config_revision(), revision(7));
|
||||
assert_eq!(pending.provider_kind().as_str(), "openai");
|
||||
assert_eq!(
|
||||
pending.owner_binding().started_request_id(),
|
||||
@@ -284,7 +278,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
"guard.finish",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.authorization_revision(), Some(revision(7)));
|
||||
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
|
||||
}
|
||||
|
||||
@@ -299,7 +292,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
@@ -307,7 +299,6 @@ fn mutation(account_id: &str) -> CredentialMutation {
|
||||
fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
CredentialDeletion {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationContext,
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
@@ -33,7 +33,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -42,7 +41,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
@@ -63,7 +61,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 0,
|
||||
refresh_concurrency: 1,
|
||||
|
||||
@@ -8,9 +8,7 @@ use gateway_admin::{
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
|
||||
};
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_disable_should_commit_then_notify_the_stateless_provider() {
|
||||
@@ -47,7 +45,6 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai"),
|
||||
expected_config_revision: revision(3),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -84,7 +81,6 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -119,7 +115,6 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -140,7 +135,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-xai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,6 @@ fn parse_sort_direction(value: &str) -> Option<SortDirection> {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPageData {
|
||||
pub config_revision: u64,
|
||||
pub items: Vec<AccountView>,
|
||||
pub page: PageMeta,
|
||||
pub summary: AccountSummaryView,
|
||||
@@ -391,31 +390,21 @@ impl AccountActionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 手工 OAuth 刷新会变更持久 credential,因此必须携带配置 CAS revision。
|
||||
/// 手工 OAuth 刷新会变更持久 credential。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountRefreshRequest {
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountRefreshRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.expected_config_revision == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
Ok(())
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
fn into_command(self) -> Result<(Revision, ProviderAccountId), WireValidationError> {
|
||||
fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok((
|
||||
Revision::new(self.expected_config_revision)
|
||||
.map_err(|_| WireValidationError::new("expectedConfigRevision"))?,
|
||||
ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
))
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +449,6 @@ pub struct AccountModelsData {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountRefreshData {
|
||||
pub config_revision: u64,
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
@@ -579,14 +567,12 @@ impl AccountProvider {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub provider: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl AccountImportRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
if !self.data.is_object()
|
||||
|| serde_json::to_vec(&self.data)
|
||||
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
|
||||
@@ -606,10 +592,6 @@ impl AccountImportRequest {
|
||||
provider,
|
||||
ImportCredentials {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
document: provider_document(self.data, "data")?,
|
||||
},
|
||||
))
|
||||
@@ -620,7 +602,6 @@ impl AccountImportRequest {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
pub expected_credential_revision: Option<u64>,
|
||||
@@ -629,7 +610,6 @@ pub struct StartAccountAuthorizationRequest {
|
||||
impl StartAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
require_text(&self.name, MAX_NAME_BYTES, "name")?;
|
||||
match (
|
||||
self.account_id.as_deref(),
|
||||
@@ -663,10 +643,6 @@ impl StartAccountAuthorizationRequest {
|
||||
provider,
|
||||
StartAuthorization {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
},
|
||||
@@ -721,14 +697,12 @@ impl CompleteAccountAuthorizationRequest {
|
||||
pub struct AccountMutationRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountMutationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
fn into_command(
|
||||
@@ -741,10 +715,6 @@ impl AccountMutationRequest {
|
||||
provider,
|
||||
CredentialMutation {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
@@ -757,7 +727,6 @@ impl AccountMutationRequest {
|
||||
pub struct AccountDeletionRequest {
|
||||
pub provider: String,
|
||||
pub account_ids: Vec<String>,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountDeletionRequest {
|
||||
@@ -773,7 +742,7 @@ impl AccountDeletionRequest {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
}
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_command(
|
||||
@@ -786,10 +755,6 @@ impl AccountDeletionRequest {
|
||||
provider,
|
||||
CredentialDeletion {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_ids: self
|
||||
.account_ids
|
||||
.into_iter()
|
||||
@@ -806,7 +771,6 @@ impl AccountDeletionRequest {
|
||||
pub struct RotateAccountRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub expected_credential_revision: u64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
@@ -818,7 +782,6 @@ impl RotateAccountRequest {
|
||||
return Err(WireValidationError::new("provider"));
|
||||
}
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
require_positive(
|
||||
self.expected_credential_revision,
|
||||
"expectedCredentialRevision",
|
||||
@@ -840,10 +803,6 @@ impl RotateAccountRequest {
|
||||
Ok(RotateCredential {
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
@@ -859,7 +818,6 @@ impl RotateAccountRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportData {
|
||||
pub config_revision: u64,
|
||||
pub imported_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
@@ -872,7 +830,6 @@ impl From<CredentialImportResult> for AccountImportData {
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
imported_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
@@ -900,7 +857,6 @@ impl From<AuthorizationStarted> for AccountAuthorizationData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountMutationData {
|
||||
pub config_revision: u64,
|
||||
pub account_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_revision: Option<u64>,
|
||||
@@ -909,7 +865,6 @@ pub struct AccountMutationData {
|
||||
impl From<CredentialMutationResult> for AccountMutationData {
|
||||
fn from(result: CredentialMutationResult) -> Self {
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
account_id: result.account_id.to_string(),
|
||||
credential_revision: result.credential_revision.map(Revision::get),
|
||||
}
|
||||
@@ -919,7 +874,6 @@ impl From<CredentialMutationResult> for AccountMutationData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDeletionData {
|
||||
pub config_revision: u64,
|
||||
pub deleted_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
@@ -932,7 +886,6 @@ impl From<CredentialDeletionResult> for AccountDeletionData {
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
deleted_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
@@ -1264,15 +1217,11 @@ async fn refresh_account<S>(
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (expected_config_revision, account_id) = request.into_command().map_err(map_wire_error)?;
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.refresh(
|
||||
&auth.context().mutation_context(),
|
||||
expected_config_revision,
|
||||
account_id,
|
||||
)
|
||||
.refresh(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
@@ -1394,7 +1343,6 @@ fn account_page_data(
|
||||
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
|
||||
};
|
||||
AccountPageData {
|
||||
config_revision: result.config_revision.get(),
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -1412,7 +1360,6 @@ fn account_page_data(
|
||||
|
||||
fn account_refresh_data(result: AccountRefreshResult, now: DateTime<Utc>) -> AccountRefreshData {
|
||||
AccountRefreshData {
|
||||
config_revision: result.config_revision.get(),
|
||||
account: account_view(result.account, now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
|
||||
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
},
|
||||
use gateway_admin::model::client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
|
||||
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
};
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
@@ -138,7 +135,6 @@ impl ClientKeySort {
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
@@ -149,14 +145,12 @@ pub struct CreateClientKeyRequest {
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
provider_kind: ProviderKind::new(self.provider_kind)
|
||||
@@ -174,7 +168,6 @@ impl CreateClientKeyRequest {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateClientKeyRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
@@ -186,14 +179,12 @@ impl UpdateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(UpdateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
@@ -207,12 +198,11 @@ impl UpdateClientKeyRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 只携带 ID 与配置 revision 的 Client Key mutation 请求。
|
||||
/// 只携带 ID 的 Client Key mutation 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClientKeyRevisionRequest {
|
||||
pub struct ClientKeyMutationRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
}
|
||||
|
||||
/// 读取一次完整 Key 的 ID query。
|
||||
@@ -233,20 +223,15 @@ impl ClientKeyIdQuery {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyRevisionRequest {
|
||||
/// 校验请求并取出 ID 与 revision。
|
||||
pub fn into_parts(self) -> Result<(String, u64), WireValidationError> {
|
||||
impl ClientKeyMutationRequest {
|
||||
/// 校验请求并取出 ID。
|
||||
pub fn into_id(self) -> Result<String, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
Ok((self.id, self.expected_config_revision))
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
fn into_command_parts(self) -> Result<(ClientApiKeyId, Revision), WireValidationError> {
|
||||
let (id, expected_config_revision) = self.into_parts()?;
|
||||
Ok((
|
||||
client_key_id(id, "clientKeyMutationNotFound")?,
|
||||
revision(expected_config_revision)?,
|
||||
))
|
||||
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +274,6 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyListData {
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
@@ -298,14 +282,8 @@ pub struct ClientKeyListData {
|
||||
impl ClientKeyListData {
|
||||
/// 构造 Client Key 列表响应。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
) -> Self {
|
||||
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
items,
|
||||
next_cursor,
|
||||
total,
|
||||
@@ -325,7 +303,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
.map(encode_client_key_cursor)
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
page.config_revision.get(),
|
||||
page.items.into_iter().map(Into::into).collect(),
|
||||
next_cursor,
|
||||
page.total,
|
||||
@@ -337,7 +314,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
prefix: String,
|
||||
plaintext_key: String,
|
||||
@@ -346,9 +322,8 @@ pub struct CreatedClientKeyData {
|
||||
impl CreatedClientKeyData {
|
||||
/// 构造一次性创建响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
prefix,
|
||||
plaintext_key,
|
||||
@@ -359,7 +334,6 @@ impl CreatedClientKeyData {
|
||||
impl From<CreatedClientKey> for CreatedClientKeyData {
|
||||
fn from(created: CreatedClientKey) -> Self {
|
||||
Self::new(
|
||||
created.config_revision.get(),
|
||||
created.secret.record.id.to_string(),
|
||||
created.secret.record.prefix.clone(),
|
||||
created.secret.expose_for_response().to_owned(),
|
||||
@@ -371,7 +345,6 @@ impl fmt::Debug for CreatedClientKeyData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreatedClientKeyData")
|
||||
.field("config_revision", &self.config_revision)
|
||||
.field("id", &self.id)
|
||||
.field("prefix", &self.prefix)
|
||||
.field("plaintext_key", &"[REDACTED]")
|
||||
@@ -417,24 +390,20 @@ impl fmt::Debug for RevealedClientKeyData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MutatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl MutatedClientKeyData {
|
||||
/// 构造 mutation 响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
}
|
||||
pub fn new(id: String) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientKeyMutation> for MutatedClientKeyData {
|
||||
fn from(mutation: ClientKeyMutation) -> Self {
|
||||
Self::new(mutation.config_revision.get(), mutation.id.to_string())
|
||||
Self::new(mutation.id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,18 +551,6 @@ fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValid
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
fn validate_revision(value: u64) -> Result<(), WireValidationError> {
|
||||
if value == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, WireValidationError> {
|
||||
validate_revision(value)?;
|
||||
Revision::new(value).map_err(|_| WireValidationError::new("expectedConfigRevision"))
|
||||
}
|
||||
|
||||
fn client_key_id(
|
||||
value: String,
|
||||
field: &'static str,
|
||||
@@ -768,23 +725,19 @@ where
|
||||
async fn disable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: false,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: false },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -793,23 +746,19 @@ where
|
||||
async fn enable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: true,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: true },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -818,23 +767,17 @@ where
|
||||
async fn delete_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.delete(
|
||||
&auth.context().mutation_context(),
|
||||
DeleteClientKey {
|
||||
expected_config_revision,
|
||||
id,
|
||||
},
|
||||
)
|
||||
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
|
||||
.await,
|
||||
)
|
||||
}
|
||||
@@ -854,9 +797,6 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
"cursor" => AdminError::bad_request("Invalid client key cursor"),
|
||||
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
|
||||
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
|
||||
"expectedConfigRevision" => {
|
||||
AdminError::bad_request("expectedConfigRevision must be positive")
|
||||
}
|
||||
_ => AdminError::bad_request("Invalid client API key request"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,8 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
},
|
||||
use gateway_admin::model::settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,7 +28,6 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -49,7 +44,6 @@ pub struct RuntimeSettingsView {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
pub expected_config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -64,9 +58,6 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.expected_config_revision == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
@@ -93,8 +84,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(self.expected_config_revision)
|
||||
.map_err(|_| WireValidationError::new("settingsRevisionOverflow"))?,
|
||||
model_mappings: domain_model_mappings(self.model_mappings)?,
|
||||
refresh_margin_seconds: self.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(self.refresh_concurrency)
|
||||
@@ -117,7 +106,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
config_revision: settings.config_revision.get(),
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: u64::from(settings.refresh_concurrency),
|
||||
@@ -358,7 +346,6 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRevisionOverflow" => "expectedConfigRevision must be positive".to_owned(),
|
||||
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
|
||||
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
|
||||
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
|
||||
|
||||
@@ -98,6 +98,7 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
| EngineError::ContinuationPinMismatch
|
||||
| EngineError::RequiredAccountMismatch
|
||||
| EngineError::DownstreamCommitRequired
|
||||
| EngineError::NoActiveAttempt
|
||||
| EngineError::InvalidDeliveryState => {
|
||||
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ mod actions {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_revision() {
|
||||
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
|
||||
let id: AccountIdQuery =
|
||||
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
|
||||
assert!(id.validate().is_ok());
|
||||
@@ -155,13 +155,16 @@ mod actions {
|
||||
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
|
||||
assert_eq!(action.validate().unwrap_err().field(), "accountId");
|
||||
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
"accountId": "acct_1"
|
||||
}))
|
||||
.expect("decode refresh");
|
||||
assert_eq!(
|
||||
refresh.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
assert!(refresh.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<AccountRefreshRequest>(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +172,6 @@ mod actions {
|
||||
fn account_import_should_use_provider_and_opaque_data_fields() {
|
||||
let valid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {
|
||||
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
|
||||
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
|
||||
@@ -180,7 +182,6 @@ mod actions {
|
||||
|
||||
let invalid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": []
|
||||
}))
|
||||
.expect("decode invalid account import");
|
||||
@@ -188,27 +189,32 @@ mod actions {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"document": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_deletion_should_validate_one_provider_batch_and_emit_account_ids() {
|
||||
let request: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_2"],
|
||||
"expectedConfigRevision": 8
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
}))
|
||||
.expect("decode account deletion");
|
||||
assert!(request.validate().is_ok());
|
||||
|
||||
let duplicate: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_1"],
|
||||
"expectedConfigRevision": 8
|
||||
"accountIds": ["acct_1", "acct_1"]
|
||||
}))
|
||||
.expect("decode duplicate account deletion");
|
||||
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
|
||||
@@ -223,7 +229,6 @@ mod actions {
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account deletion"),
|
||||
json!({
|
||||
"configRevision": 9,
|
||||
"deletedCount": 2,
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
})
|
||||
@@ -239,7 +244,6 @@ mod actions {
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account import"),
|
||||
json!({
|
||||
"configRevision": 8,
|
||||
"importedCount": 1,
|
||||
"accountIds": ["acct_imported"]
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use gateway_api::admin::client_keys::{
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyRevisionRequest,
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
|
||||
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
|
||||
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
|
||||
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
|
||||
@@ -101,9 +101,8 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields() {
|
||||
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"label": "production",
|
||||
"providerKind": "openai",
|
||||
@@ -113,22 +112,11 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
.expect("deserialize create")
|
||||
.into_command()
|
||||
.expect("validate create");
|
||||
assert_eq!(valid.expected_config_revision.get(), 7);
|
||||
assert_eq!(valid.name, "terminal key");
|
||||
|
||||
for (payload, field) in [
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 0,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
"expectedConfigRevision",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": " ",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
@@ -138,7 +126,6 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": u64::MAX,
|
||||
@@ -170,14 +157,23 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let revision = serde_json::from_value::<ClientKeyRevisionRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 3
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
|
||||
"id": "key_1"
|
||||
}))
|
||||
.expect("deserialize revision mutation")
|
||||
.into_parts()
|
||||
.expect("validate revision mutation");
|
||||
assert_eq!(revision, ("key_1".to_owned(), 3));
|
||||
.expect("deserialize mutation")
|
||||
.into_id()
|
||||
.expect("validate mutation");
|
||||
assert_eq!(mutation_id, "key_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,9 +221,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
});
|
||||
let list = serde_json::to_value(ClientKeyListData::new(9, vec![view], None, 1))
|
||||
.expect("serialize list");
|
||||
assert_eq!(list["configRevision"], 9);
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["items"][0]["id"], "key_visible");
|
||||
assert_eq!(list["items"][0]["providerKind"], "openai");
|
||||
@@ -248,7 +244,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
|
||||
let plaintext = format!("sk_{}", "a".repeat(43));
|
||||
let created = CreatedClientKeyData::new(
|
||||
10,
|
||||
"key_created".to_owned(),
|
||||
"sk_aaaaaaaaa".to_owned(),
|
||||
plaintext.clone(),
|
||||
@@ -263,9 +258,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
json!({ "id": "key_created", "plaintextKey": plaintext })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MutatedClientKeyData::new(11, "key_created".to_owned()))
|
||||
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
|
||||
.expect("serialize mutation"),
|
||||
json!({ "configRevision": 11, "id": "key_created" })
|
||||
json!({ "id": "key_created" })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -279,8 +279,9 @@ impl SettingsStore for MemorySettingsStore {
|
||||
command: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
config_revision: next_revision(command.expected_config_revision),
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
model_mappings: command.model_mappings,
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
@@ -292,31 +293,31 @@ impl SettingsStore for MemorySettingsStore {
|
||||
audit_retention_days: command.audit_retention_days,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*self.settings.lock().expect("settings") = updated.clone();
|
||||
*settings = updated.clone();
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = Some(key);
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = None;
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
|
||||
|
||||
fn update_body() -> Value {
|
||||
json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"modelMappings": {
|
||||
"gpt-5.4": "gpt-5.5",
|
||||
"grok-latest": "grok-4.5"
|
||||
@@ -64,22 +63,11 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_require_positive_revision() {
|
||||
fn settings_request_should_reject_unknown_revision_field() {
|
||||
let mut body = update_body();
|
||||
body["expectedConfigRevision"] = json!(0);
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
assert_eq!(
|
||||
request.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
);
|
||||
body["expectedConfigRevision"] = json!(7);
|
||||
|
||||
let mut missing = update_body();
|
||||
missing
|
||||
.as_object_mut()
|
||||
.expect("settings object")
|
||||
.remove("expectedConfigRevision");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,7 +112,7 @@ async fn settings_post_should_replace_global_model_mappings() {
|
||||
.expect("settings update response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert_eq!(data["configRevision"], 8);
|
||||
assert!(data.get("configRevision").is_none());
|
||||
assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5");
|
||||
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use gateway_api::openai::responses::ResponseCreateFrameError;
|
||||
use gateway_core::operation::Operation;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::decode_response_create;
|
||||
use crate::openai::{api_router, models::ModelsExecution};
|
||||
|
||||
#[test]
|
||||
fn response_create_should_default_to_the_websocket_streaming_contract() {
|
||||
@@ -141,3 +147,84 @@ fn response_create_should_reject_non_boolean_stream_without_disclosing_body_valu
|
||||
assert!(!rendered.contains(prompt));
|
||||
assert!(!rendered.contains(opaque_stream_value));
|
||||
}
|
||||
|
||||
// `serve_responses_websocket`/`forward_execution` 只有拿到 `hyper::upgrade::OnUpgrade`
|
||||
// 的真实 HTTP/1.1 升级连接才会运行;本 crate 的依赖图按 axum `ws` feature 编译
|
||||
// hyper(无 `http1`),测试进程内不存在能完成升级的服务端,`hyper::upgrade` 也不
|
||||
// 提供公开构造器。集成测试因此止步于 upgrade 边界:会话循环内的事件转发、错误帧
|
||||
// 与断连取消仍未被覆盖,需要 dev 依赖开启 `http1` 或 socket 泛化后才能落地。
|
||||
|
||||
const TEST_WEBSOCKET_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
fn upgrade_request(authorization: &str) -> Request<Body> {
|
||||
Request::get("/v1/responses")
|
||||
.header(AUTHORIZATION, authorization)
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", TEST_WEBSOCKET_KEY)
|
||||
.body(Body::empty())
|
||||
.expect("build WebSocket upgrade request")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_responses_should_route_to_the_websocket_upgrade_boundary() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(upgrade_request("Bearer sk_models_test"))
|
||||
.await
|
||||
.expect("route WebSocket upgrade request");
|
||||
|
||||
// oneshot 请求不携带升级状态;426 证明 GET /v1/responses 进入的是
|
||||
// WebSocketUpgrade 边界而不是普通 HTTP handler。
|
||||
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_upgradability_should_be_checked_before_authentication() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(upgrade_request("Bearer sk_invalid"))
|
||||
.await
|
||||
.expect("route unauthenticated upgrade request");
|
||||
|
||||
// 升级能力在 extractor 阶段先于 handler 内的 API Key 认证被校验,
|
||||
// 无效凭据得到的仍是升级失败而不是 401。
|
||||
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_upgrade_should_reject_malformed_handshakes() {
|
||||
let missing_connection = Request::get("/v1/responses")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", TEST_WEBSOCKET_KEY)
|
||||
.body(Body::empty())
|
||||
.expect("build request without connection header");
|
||||
let unsupported_version = Request::get("/v1/responses")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "12")
|
||||
.header("sec-websocket-key", TEST_WEBSOCKET_KEY)
|
||||
.body(Body::empty())
|
||||
.expect("build request with unsupported version");
|
||||
let missing_key = Request::get("/v1/responses")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.expect("build request without websocket key");
|
||||
|
||||
for request in [missing_connection, unsupported_version, missing_key] {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("route malformed WebSocket handshake");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ where
|
||||
attempts: 0,
|
||||
excluded_accounts: BTreeSet::new(),
|
||||
credential_recovery_attempted_accounts: BTreeSet::new(),
|
||||
recovery_account: None,
|
||||
current: None,
|
||||
send_state_watermark: UpstreamSendState::NotSent,
|
||||
downstream_committed_at: None,
|
||||
@@ -165,6 +166,10 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
attempts: u32,
|
||||
excluded_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
|
||||
credential_recovery_attempted_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
|
||||
/// 凭据恢复后的一次性同账号钉选;只约束紧随其后的 replay attempt,
|
||||
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
|
||||
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
|
||||
recovery_account: Option<crate::engine::credential::ProviderAccountId>,
|
||||
current: Option<CurrentAttempt>,
|
||||
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
|
||||
send_state_watermark: UpstreamSendState,
|
||||
@@ -381,7 +386,7 @@ where
|
||||
}
|
||||
|
||||
let boundary = {
|
||||
let current = self.current.as_mut().ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
|
||||
poll_stream_item(
|
||||
&mut current.stream,
|
||||
self.cancellation.clone(),
|
||||
@@ -397,7 +402,9 @@ where
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
PollBoundary::Deadline => {
|
||||
self.record_current_provider_failure(ProviderErrorKind::Timeout);
|
||||
// 会话 deadline 是网关自身的请求预算,不是上游超时;
|
||||
// 真正的上游超时会作为流错误进入 `handle_stream_error` 记账。
|
||||
// 这里不写 provider 失败,避免长流集中到期误触 provider 熔断。
|
||||
self.finish_interruption(EngineError::Deadline).await?;
|
||||
return Err(EngineError::Deadline);
|
||||
}
|
||||
@@ -474,6 +481,12 @@ where
|
||||
.checked_add(1)
|
||||
.and_then(NonZeroU32::new)
|
||||
.ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
// recovery 钉选在此被一次性消费,只绑定本次 replay attempt;
|
||||
// 外部 required_account 每次 attempt 都重新生效。
|
||||
let pinned_account = self
|
||||
.recovery_account
|
||||
.take()
|
||||
.or_else(|| self.required_account.clone());
|
||||
let context = AttemptContext::new(
|
||||
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
|
||||
.with_timing_started_at(self.timing_started_at),
|
||||
@@ -482,15 +495,15 @@ where
|
||||
self.plan.account_selection_policy(),
|
||||
AccountAttemptContext::new(
|
||||
self.excluded_accounts.clone(),
|
||||
self.required_account.clone(),
|
||||
pinned_account.clone(),
|
||||
self.account_state_owner.clone(),
|
||||
)
|
||||
.with_credential_recovery_attempted(
|
||||
self.required_account.as_ref().is_some_and(|account| {
|
||||
.with_credential_recovery_attempted(pinned_account.as_ref().is_some_and(
|
||||
|account| {
|
||||
self.credential_recovery_attempted_accounts
|
||||
.contains(account)
|
||||
}),
|
||||
),
|
||||
},
|
||||
)),
|
||||
self.continuation.clone(),
|
||||
self.cancellation.clone(),
|
||||
)
|
||||
@@ -523,10 +536,9 @@ where
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
ProviderBoundary::Deadline => {
|
||||
self.record_provider_failure(
|
||||
candidate.provider().clone(),
|
||||
ProviderErrorKind::Timeout,
|
||||
);
|
||||
// 网关预算到期同样不是候选 Provider 的上游超时,不计入熔断;
|
||||
// Provider 自身的握手/传输超时会以 `ProviderErrorKind::Timeout`
|
||||
// 错误返回并在下方 `Result` 分支记账。
|
||||
self.finish_interruption(EngineError::Deadline).await?;
|
||||
return Err(EngineError::Deadline);
|
||||
}
|
||||
@@ -573,8 +585,7 @@ where
|
||||
}
|
||||
|
||||
let metadata = stream.metadata().clone();
|
||||
if self
|
||||
.required_account
|
||||
if pinned_account
|
||||
.as_ref()
|
||||
.is_some_and(|required| metadata.provider_account_id() != Some(required))
|
||||
{
|
||||
@@ -678,7 +689,7 @@ where
|
||||
if let GatewayEvent::ProviderCost(observed) = event {
|
||||
self.cost = observed.into_estimate();
|
||||
}
|
||||
let current = self.current.as_mut().ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if !current.send_observed {
|
||||
self.engine
|
||||
.store()
|
||||
@@ -691,7 +702,7 @@ where
|
||||
}
|
||||
|
||||
async fn observe_wire_event(&mut self) -> Result<(), EngineError> {
|
||||
let current = self.current.as_mut().ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if !current.send_observed {
|
||||
self.engine
|
||||
.store()
|
||||
@@ -779,7 +790,7 @@ where
|
||||
|
||||
/// 返回 `true` 表示调用方必须丢弃本 attempt 已收集的未提交事件。
|
||||
async fn handle_stream_error(&mut self, error: ProviderError) -> Result<bool, EngineError> {
|
||||
let current = self.current.take().ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted
|
||||
@@ -836,7 +847,9 @@ where
|
||||
if let Some(account) = current.metadata.provider_account_id() {
|
||||
self.credential_recovery_attempted_accounts
|
||||
.insert(account.clone());
|
||||
self.required_account = Some(account.clone());
|
||||
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
|
||||
// ordinary/continuation 重试门不受影响,仍可换号。
|
||||
self.recovery_account = Some(account.clone());
|
||||
}
|
||||
} else if !continuation_retry
|
||||
&& let Some(account) = current.metadata.provider_account_id()
|
||||
@@ -930,6 +943,7 @@ where
|
||||
self.timings.first_reasoning_ms = None;
|
||||
self.timings.first_text_ms = None;
|
||||
self.timings.first_token_ms = None;
|
||||
self.timings.provider_processing_ms = None;
|
||||
self.upstream_complete = false;
|
||||
}
|
||||
|
||||
@@ -959,8 +973,7 @@ where
|
||||
.current
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::status_code)
|
||||
.or(Some(200));
|
||||
.and_then(ProviderResponseObservation::status_code);
|
||||
let (upstream_transport, http_version, websocket_pool) =
|
||||
self.current_transport_observation();
|
||||
let provider_metadata_json = self.current_provider_metadata_json();
|
||||
@@ -1209,16 +1222,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn record_current_provider_failure(&mut self, error_kind: ProviderErrorKind) {
|
||||
let provider_kind = self
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|current| current.metadata.provider().clone());
|
||||
if let Some(provider_kind) = provider_kind {
|
||||
self.record_provider_failure(provider_kind, error_kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_provider_failure(
|
||||
&mut self,
|
||||
provider_kind: crate::routing::ProviderKind,
|
||||
|
||||
@@ -20,10 +20,10 @@ use crate::engine::coordinator::ResponseExecutionSession;
|
||||
use crate::engine::probe::{AccountProbe, AccountProbeRequest, AccountProbeResult};
|
||||
use crate::engine::provider::ProviderRegistry;
|
||||
use crate::engine::{
|
||||
AttemptCoordinator, AttemptRecord, CancellationToken, CommitRequirement, CoordinatedEvent,
|
||||
EngineError, ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization,
|
||||
ModelRequestId, NewModelRequest, ProbeFailure, ProviderAccountId, ProviderAttemptOutcome,
|
||||
RecoveryReport, UpstreamSendState,
|
||||
AttemptCoordinator, AttemptRecord, CancellationToken, CoordinatedEvent, EngineError,
|
||||
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
|
||||
NewModelRequest, ProbeFailure, ProviderAccountId, ProviderAttemptOutcome, RecoveryReport,
|
||||
UpstreamSendState,
|
||||
};
|
||||
use crate::error::{GatewayError, GatewayErrorKind, ProviderErrorKind, StoreError};
|
||||
use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
|
||||
@@ -943,8 +943,3 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
_ => GatewayError::new(GatewayErrorKind::Internal, "request execution failed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn commit_requirement(event: &CoordinatedEvent) -> CommitRequirement {
|
||||
event.commit_requirement()
|
||||
}
|
||||
|
||||
@@ -673,11 +673,7 @@ impl CoordinatedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建一个必须在同一下游提交边界内按序编码的事件批次。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空批次无法建立交付边界。
|
||||
/// 将一批 provider 事件合并为单个提交单元;空批次是无效投递状态。
|
||||
pub fn try_batch(
|
||||
events: Vec<ProviderEvent>,
|
||||
commit_requirement: CommitRequirement,
|
||||
@@ -728,6 +724,8 @@ pub enum EngineError {
|
||||
Deadline,
|
||||
#[error("routing plan has no candidate")]
|
||||
EmptyRoutingPlan,
|
||||
#[error("no provider attempt is active")]
|
||||
NoActiveAttempt,
|
||||
#[error("downstream delivery must be committed before execution can continue")]
|
||||
DownstreamCommitRequired,
|
||||
#[error("downstream delivery cannot be committed in the current state")]
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use futures::executor::block_on;
|
||||
|
||||
use gateway_core::accounting::{CalculatedCost, CostSource, ProviderReportedCost, Usage};
|
||||
@@ -15,18 +16,20 @@ use gateway_core::engine::continuation::{
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, CredentialRevision, ProviderAccountId, RotationStrategy,
|
||||
};
|
||||
use gateway_core::engine::execution::gateway_error_from_engine;
|
||||
use gateway_core::engine::provider::{
|
||||
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
|
||||
ProviderRegistry, ProviderRequest, ProviderResource, ProviderStream, UpstreamTransport,
|
||||
};
|
||||
use gateway_core::engine::{
|
||||
AttemptContext, AttemptCoordinator, AttemptRecord, CancellationToken, CommitRequirement,
|
||||
ContinuationAttempt, ExecutionOutcome, ExecutionStore, GatewayEngine, IntermediateFailure,
|
||||
ModelRequestFinalization, ModelRequestId, NewModelRequest, ProviderAttemptOutcome,
|
||||
RecoveryReport, UpstreamSendState,
|
||||
ContinuationAttempt, EngineError, ExecutionOutcome, ExecutionStore, GatewayEngine,
|
||||
IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
|
||||
ProviderAttemptOutcome, RecoveryReport, UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::{
|
||||
ContinuationFailure, ProviderError, ProviderErrorKind, SafeUpstreamValue, StoreError,
|
||||
ContinuationFailure, GatewayErrorKind, ProviderError, ProviderErrorKind, SafeUpstreamValue,
|
||||
StoreError,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
ContentItem, ContentKind, GatewayEvent, ProtocolWireEvent, ProviderEvent,
|
||||
@@ -221,6 +224,11 @@ enum Script {
|
||||
account_id: &'static str,
|
||||
items: Vec<Result<GatewayEvent, ProviderError>>,
|
||||
},
|
||||
/// 产出 `items` 后永久悬挂的流;用于逼出会话级 deadline。
|
||||
HangingStream {
|
||||
account_id: &'static str,
|
||||
items: Vec<Result<GatewayEvent, ProviderError>>,
|
||||
},
|
||||
ObservedStream {
|
||||
account_id: &'static str,
|
||||
items: Vec<Result<ProviderEvent, ProviderError>>,
|
||||
@@ -296,6 +304,30 @@ impl Provider for ScriptedProvider {
|
||||
(),
|
||||
))
|
||||
}
|
||||
Script::HangingStream { account_id, items } => {
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().clone(),
|
||||
ProviderResource::Account {
|
||||
id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
revision: CredentialRevision::new(1).expect("revision"),
|
||||
},
|
||||
UpstreamTransport::new("http_sse").expect("transport"),
|
||||
);
|
||||
Ok(ProviderStream::new(
|
||||
metadata,
|
||||
Box::pin(
|
||||
futures::stream::iter(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|event| event.map(ProviderEvent::from)),
|
||||
)
|
||||
.chain(futures::stream::pending()),
|
||||
),
|
||||
(),
|
||||
))
|
||||
}
|
||||
Script::ObservedStream { account_id, items } => {
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
@@ -855,6 +887,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
connect_ms: Some(11),
|
||||
headers_ms: Some(13),
|
||||
first_event_ms: Some(987_654),
|
||||
provider_processing_ms: Some(41),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let second_observation = ProviderResponseObservation::new(
|
||||
@@ -910,6 +943,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
assert_eq!(finalization.transport_decision_wait_ms, None);
|
||||
assert_eq!(finalization.connect_ms, None);
|
||||
assert_eq!(finalization.headers_ms, None);
|
||||
assert_eq!(finalization.provider_processing_ms, None);
|
||||
assert!(
|
||||
finalization
|
||||
.first_event_ms
|
||||
@@ -948,6 +982,44 @@ fn websocket_success_keeps_client_http_status_absent() {
|
||||
assert_eq!(state.finalizations[0].client_status_code, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_without_response_observation_persists_no_upstream_status() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_one",
|
||||
items: complete_stream(None),
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
block_on(session.collect_uncommitted()).expect("collect response");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
assert_eq!(state.finalizations[0].upstream_status_code, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_attempt_is_classified_internal_not_no_available_provider() {
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&EngineError::EmptyRoutingPlan).kind(),
|
||||
GatewayErrorKind::NoAvailableProvider
|
||||
);
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&EngineError::NoActiveAttempt).kind(),
|
||||
GatewayErrorKind::Internal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_native_continuation_reaches_every_attempt_context() {
|
||||
let operation = generate_operation();
|
||||
@@ -1524,6 +1596,62 @@ fn recovered_credential_retries_the_same_account_exactly_once() {
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_error_after_credential_recovery_switches_account_instead_of_terminating() {
|
||||
let operation = operation(RetrySafety::NonIdempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Unauthorized,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(401)
|
||||
.with_replay_safe()
|
||||
.with_same_account_retry())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(429)
|
||||
.with_replay_safe())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("third attempt succeeds on another account");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
let first = ProviderAccountId::new("acct_first").expect("account");
|
||||
assert_eq!(contexts.len(), 3);
|
||||
assert_eq!(contexts[1].required_account(), Some(&first));
|
||||
assert!(contexts[1].credential_recovery_attempted());
|
||||
// recovery 钉账号只绑定 replay attempt;replay 上的 429 之后必须能换号。
|
||||
assert_eq!(contexts[2].required_account(), None);
|
||||
assert!(contexts[2].excluded_accounts().contains(&first));
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.attempts.len(), 3);
|
||||
assert_eq!(state.intermediate_failures, 2);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_idempotent_explicit_429_rejection_rotates_account_before_output() {
|
||||
let operation = operation(RetrySafety::NonIdempotent);
|
||||
@@ -1953,3 +2081,71 @@ fn expired_deadline_finalizes_without_calling_provider() {
|
||||
assert!(state.attempts.is_empty());
|
||||
assert!(state.finalizations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_after_commit_is_incomplete_without_provider_circuit_failure() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::HangingStream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Ok(GatewayEvent::Started(ResponseMeta::new(
|
||||
"response-1",
|
||||
"gpt-5",
|
||||
)))],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_millis(300)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
let first = block_on(session.next_event())
|
||||
.expect("first event")
|
||||
.expect("started event");
|
||||
assert_eq!(
|
||||
first.commit_requirement(),
|
||||
CommitRequirement::CommitBeforeDelivery
|
||||
);
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit started event");
|
||||
|
||||
let error = block_on(session.next_event()).expect_err("deadline elapses mid-stream");
|
||||
|
||||
assert!(matches!(error, EngineError::Deadline));
|
||||
// 网关自身请求预算到期不是上游超时;已在交付中的长流集中到期
|
||||
// 不得作为 provider Timeout 计入熔断。
|
||||
assert!(session.provider_attempt_outcomes().is_empty());
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
|
||||
assert!(state.finalizations[0].committed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_before_first_event_records_no_provider_circuit_failure() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::HangingStream {
|
||||
account_id: "acct_first",
|
||||
items: Vec::new(),
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_millis(300)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let error = block_on(session.next_event()).expect_err("deadline elapses before first event");
|
||||
|
||||
assert!(matches!(error, EngineError::Deadline));
|
||||
assert!(session.provider_attempt_outcomes().is_empty());
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.attempts.len(), 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
|
||||
assert!(!state.finalizations[0].committed);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pub mod config;
|
||||
mod logging;
|
||||
mod serve;
|
||||
pub mod serve;
|
||||
pub mod system_update;
|
||||
pub mod workers;
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ use tokio::sync::Notify;
|
||||
const DRAINING_BIT: usize = 1usize << (usize::BITS - 1);
|
||||
const ACTIVE_MASK: usize = !DRAINING_BIT;
|
||||
|
||||
pub(crate) struct ConnectionTracker {
|
||||
/// 自重启交接没有显式握手:替换进程按 CPR_RESTART_DELAY_MS 估算等待,
|
||||
/// 可能在旧进程释放监听端口前尝试绑定,因此对 AddrInUse 保留有限重试窗口。
|
||||
const BIND_RETRY_WINDOW: Duration = Duration::from_secs(10);
|
||||
const BIND_RETRY_MAX_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
pub struct ConnectionTracker {
|
||||
state: Arc<ConnectionState>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
@@ -23,7 +28,8 @@ struct ConnectionState {
|
||||
}
|
||||
|
||||
impl ConnectionTracker {
|
||||
pub(crate) fn new(cancellation: CancellationToken) -> Self {
|
||||
#[must_use]
|
||||
pub fn new(cancellation: CancellationToken) -> Self {
|
||||
Self {
|
||||
state: Arc::new(ConnectionState {
|
||||
value: AtomicUsize::new(0),
|
||||
@@ -41,10 +47,15 @@ impl ConnectionTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_idle(&self, timeout: Duration) {
|
||||
pub async fn wait_until_idle(&self, timeout: Duration) {
|
||||
let wait = async {
|
||||
loop {
|
||||
let notified = self.state.idle.notified();
|
||||
tokio::pin!(notified);
|
||||
// notify_waiters 不保存许可:必须先 enable 注册等待者,再读
|
||||
// 活跃计数;否则最后一个 guard 在读数与首次 poll 之间 drop
|
||||
// 时唤醒丢失,只能等满整个超时。
|
||||
notified.as_mut().enable();
|
||||
if self.state.value.load(Ordering::Acquire) & ACTIVE_MASK == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -102,6 +113,27 @@ impl Drop for ActiveConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定失败即进程退出、服务彻底离线,因此 AddrInUse(典型为自重启交接
|
||||
/// 时旧进程尚未关闭 listener)在窗口内指数退避重试;其他错误立即上抛。
|
||||
pub async fn bind_listener(address: &str) -> std::io::Result<tokio::net::TcpListener> {
|
||||
let deadline = tokio::time::Instant::now() + BIND_RETRY_WINDOW;
|
||||
let mut delay = Duration::from_millis(100);
|
||||
loop {
|
||||
match tokio::net::TcpListener::bind(address).await {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::AddrInUse
|
||||
&& tokio::time::Instant::now() < deadline =>
|
||||
{
|
||||
tracing::warn!(target: "gateway_startup", address, "监听地址仍被占用,退避后重试绑定");
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(BIND_RETRY_MAX_DELAY);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn serve_router(
|
||||
router: Router,
|
||||
host: &str,
|
||||
@@ -110,7 +142,7 @@ pub(crate) async fn serve_router(
|
||||
connections: Arc<ConnectionTracker>,
|
||||
drain_timeout: Duration,
|
||||
) -> Result<(), ServeError> {
|
||||
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
|
||||
let listener = bind_listener(&format!("{host}:{port}"))
|
||||
.await
|
||||
.map_err(ServeError::Bind)?;
|
||||
tracing::info!(target: "gateway_startup", host, port, "网关开始监听");
|
||||
|
||||
@@ -239,7 +239,13 @@ impl ProcessSystemOperations {
|
||||
.ok_or_else(|| conflict("update repository is not configured"))?;
|
||||
self.events
|
||||
.info(None, Some("release"), "正在获取最新 Release 信息");
|
||||
let release = match fetch_latest(&self.config.github_api_base, repository).await {
|
||||
let release = match fetch_latest(
|
||||
&self.config.github_api_base,
|
||||
repository,
|
||||
&self.config.update_channel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
self.events
|
||||
@@ -495,6 +501,12 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, OperationError> {
|
||||
// 与 update/rollback 互斥:更新替换文件期间触发自重启会让新进程
|
||||
// 载入半成品产物。
|
||||
let _operation = self
|
||||
.operation_lock
|
||||
.try_lock()
|
||||
.map_err(|_| conflict("system operation is already running"))?;
|
||||
if !self.config.self_restart_enabled {
|
||||
return Err(conflict("self restart is disabled"));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ pub(crate) struct GitHubRelease {
|
||||
pub(crate) html_url: Option<String>,
|
||||
pub(crate) prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) draft: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ impl ReleaseCache {
|
||||
if !refresh && let Some(detail) = self.cached(&key).await {
|
||||
return Ok(detail);
|
||||
}
|
||||
match fetch_latest(&config.github_api_base, repository).await {
|
||||
match fetch_latest(&config.github_api_base, repository, &config.update_channel).await {
|
||||
Ok(release) => {
|
||||
let detail = detail_from_release(config, &release);
|
||||
*self.entry.lock().await = Some(CachedRelease {
|
||||
@@ -85,13 +87,27 @@ impl ReleaseCache {
|
||||
pub(crate) async fn fetch_latest(
|
||||
api_base: &str,
|
||||
repository: &str,
|
||||
update_channel: &str,
|
||||
) -> Result<GitHubRelease, OperationError> {
|
||||
validate_api_base(api_base).map_err(conflict)?;
|
||||
validate_repository(repository)?;
|
||||
let url = format!(
|
||||
"{}/{repository}/releases/latest",
|
||||
api_base.trim_end_matches('/')
|
||||
);
|
||||
let base = api_base.trim_end_matches('/');
|
||||
// GitHub 的 /releases/latest 永远只返回正式版;preview 渠道需要覆盖
|
||||
// prerelease,因此改查列表端点并取最新一条非草稿 Release。
|
||||
if update_channel == "preview" {
|
||||
let releases: Vec<GitHubRelease> =
|
||||
fetch_release_json(&format!("{base}/{repository}/releases?per_page=10")).await?;
|
||||
return releases
|
||||
.into_iter()
|
||||
.find(|release| !release.draft)
|
||||
.ok_or_else(|| upstream("no published release available"));
|
||||
}
|
||||
fetch_release_json(&format!("{base}/{repository}/releases/latest")).await
|
||||
}
|
||||
|
||||
async fn fetch_release_json<T: serde::de::DeserializeOwned>(
|
||||
url: &str,
|
||||
) -> Result<T, OperationError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
@@ -111,7 +127,7 @@ pub(crate) async fn fetch_latest(
|
||||
)));
|
||||
}
|
||||
response
|
||||
.json::<GitHubRelease>()
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|error| upstream(format!("invalid GitHub release response: {error}")))
|
||||
}
|
||||
@@ -282,7 +298,6 @@ fn repository_character(character: char) -> bool {
|
||||
fn platform_os_aliases() -> &'static [&'static str] {
|
||||
match env::consts::OS {
|
||||
"macos" => &["macos", "darwin"],
|
||||
"windows" => &["windows", "win32"],
|
||||
"linux" => &["linux"],
|
||||
_ => &[env::consts::OS],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gateway_core::engine::CancellationToken;
|
||||
use gateway_core::lifecycle::ConnectionLifecycle as _;
|
||||
use gateway_host::HostBundle;
|
||||
use gateway_host::serve::{ConnectionTracker, bind_listener};
|
||||
|
||||
#[test]
|
||||
fn host_bundle_serve_is_a_consuming_process_entrypoint() {
|
||||
@@ -6,3 +12,83 @@ fn host_bundle_serve_is_a_consuming_process_entrypoint() {
|
||||
|
||||
assert_eq!(std::mem::size_of_val(&_serve), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn wait_until_idle_should_return_immediately_without_active_connections() {
|
||||
let tracker = ConnectionTracker::new(CancellationToken::new());
|
||||
let started = tokio::time::Instant::now();
|
||||
|
||||
tracker.wait_until_idle(Duration::from_secs(30)).await;
|
||||
|
||||
assert_eq!(started.elapsed(), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn wait_until_idle_should_give_up_at_timeout_while_connections_remain() {
|
||||
let tracker = ConnectionTracker::new(CancellationToken::new());
|
||||
let _guard = tracker.try_register().expect("register connection");
|
||||
let started = tokio::time::Instant::now();
|
||||
|
||||
tracker.wait_until_idle(Duration::from_secs(30)).await;
|
||||
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_until_idle_should_wake_when_last_guard_drops_after_first_poll() {
|
||||
let tracker = ConnectionTracker::new(CancellationToken::new());
|
||||
let guard = tracker.try_register().expect("register connection");
|
||||
let mut wait = Box::pin(tracker.wait_until_idle(Duration::from_secs(30)));
|
||||
assert!(futures::poll!(wait.as_mut()).is_pending());
|
||||
|
||||
drop(guard);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), wait)
|
||||
.await
|
||||
.expect("woken by last guard drop instead of waiting out the timeout");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn wait_until_idle_should_observe_guard_drop_racing_the_idle_check() {
|
||||
// 回归约束:notified 必须在读取活跃计数前完成注册(enable),否则最后
|
||||
// 一个 guard 在计数检查与首次 poll 之间 drop 时唤醒丢失,只能等满整个
|
||||
// 超时。多线程反复交错,任何一次丢唤醒都会撞上 2s 超时并触发断言。
|
||||
for _ in 0..256 {
|
||||
let tracker = Arc::new(ConnectionTracker::new(CancellationToken::new()));
|
||||
let guard = tracker.try_register().expect("register connection");
|
||||
let waiter = tokio::spawn({
|
||||
let tracker = Arc::clone(&tracker);
|
||||
async move { tracker.wait_until_idle(Duration::from_secs(2)).await }
|
||||
});
|
||||
let dropper = std::thread::spawn(move || drop(guard));
|
||||
let started = std::time::Instant::now();
|
||||
waiter.await.expect("waiter completes");
|
||||
dropper.join().expect("dropper completes");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(1_900),
|
||||
"idle wakeup was lost and the waiter slept until the drain timeout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_listener_should_retry_until_previous_listener_releases_port() {
|
||||
let holder = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("holder listener");
|
||||
let address = holder.local_addr().expect("holder address").to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
drop(holder);
|
||||
});
|
||||
|
||||
let listener = tokio::time::timeout(Duration::from_secs(8), bind_listener(&address))
|
||||
.await
|
||||
.expect("bound within the retry window")
|
||||
.expect("bound after the previous listener released the port");
|
||||
|
||||
assert_eq!(
|
||||
listener.local_addr().expect("bound address").to_string(),
|
||||
address
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use filetime::FileTime;
|
||||
@@ -79,6 +80,49 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
.expect("replacement executed before fixture cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_should_conflict_while_another_system_operation_is_running() {
|
||||
let fixture = Fixture::new();
|
||||
// 裸 TCP 监听不回包:update 持有操作锁后停在 release 拉取阶段,accept
|
||||
// 信号保证断言时锁一定已被占用。
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("release listener");
|
||||
let api_base = format!("http://{}/repos", listener.local_addr().expect("addr"));
|
||||
let (connected_tx, connected_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let connection = listener.accept().await;
|
||||
let _ = connected_tx.send(());
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
drop(connection);
|
||||
});
|
||||
let mut config = fixture.config(&api_base);
|
||||
config.self_restart_enabled = true;
|
||||
config.deployment_mode = "docker".to_owned();
|
||||
let shutdown = CancellationToken::new();
|
||||
let service = Arc::new(ProcessSystemOperations::new(shutdown.clone(), config));
|
||||
let update = tokio::spawn({
|
||||
let service = Arc::clone(&service);
|
||||
async move {
|
||||
service
|
||||
.perform_update(Some(TARGET_VERSION.to_owned()))
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), connected_rx)
|
||||
.await
|
||||
.expect("update reaches release fetch while holding the operation lock")
|
||||
.expect("connected signal");
|
||||
|
||||
let error = service.restart().await.expect_err("restart during update");
|
||||
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
|
||||
assert!(!shutdown.is_cancelled());
|
||||
|
||||
update.abort();
|
||||
let _ = update.await;
|
||||
service.restart().await.expect("restart after lock release");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_should_restore_binary_web_and_version_state() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -135,6 +179,40 @@ async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_detail_should_offer_latest_non_draft_prerelease_on_preview_channel() {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/owner/repository/releases"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
|
||||
{
|
||||
"tag_name": "v1.2.0-beta.2",
|
||||
"name": "Draft 1.2.0-beta.2",
|
||||
"prerelease": true,
|
||||
"draft": true,
|
||||
"assets": [],
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.1.0-beta.1",
|
||||
"name": "Release 1.1.0-beta.1",
|
||||
"body": "notes",
|
||||
"html_url": "https://github.com/owner/repository/releases",
|
||||
"prerelease": true,
|
||||
"assets": [],
|
||||
},
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = fixture.config(&format!("{}/repos", server.uri()));
|
||||
config.update_channel = "preview".to_owned();
|
||||
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
|
||||
|
||||
let detail = service.update_detail(true).await.expect("detail");
|
||||
assert_eq!(detail.latest_version, "1.1.0-beta.1");
|
||||
assert!(detail.has_update);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_detail_should_reject_untrusted_github_api_base() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -582,7 +582,6 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
};
|
||||
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
|
||||
&self.control_plane,
|
||||
store_revision(command.expected_config_revision)?,
|
||||
replacement,
|
||||
)
|
||||
.await
|
||||
@@ -592,32 +591,24 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
self.replace_admin_api_key_value(
|
||||
expected_config_revision,
|
||||
Some(key.expose_for_auth().to_owned()),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
self.replace_admin_api_key_value(Some(key.expose_for_auth().to_owned()), context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
self.replace_admin_api_key_value(expected_config_revision, None, context)
|
||||
.await
|
||||
self.replace_admin_api_key_value(None, context).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminSettingsStoreAdapter {
|
||||
async fn replace_admin_api_key_value(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
admin_api_key: Option<String>,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -639,7 +630,6 @@ impl AdminSettingsStoreAdapter {
|
||||
};
|
||||
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
|
||||
&self.control_plane,
|
||||
store_revision(expected_config_revision)?,
|
||||
postgres::ControlPlaneReplacement {
|
||||
settings: update,
|
||||
audit: mutation_audit(
|
||||
|
||||
@@ -36,7 +36,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Transaction};
|
||||
|
||||
use crate::{
|
||||
StoreError, StoreResult, admin_revision, admin_store_error, mutation_audit,
|
||||
postgres_unavailable, require_nonempty, store_revision,
|
||||
postgres_unavailable, require_nonempty,
|
||||
};
|
||||
|
||||
use super::{ControlPlaneRepository, PgControlPlaneRepository};
|
||||
@@ -633,7 +633,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.create_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
NewClientApiKey {
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
@@ -676,7 +675,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.update_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
UpdateClientApiKeyDetails {
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
@@ -716,7 +714,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.set_client_api_key_enabled(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
id.as_str(),
|
||||
command.enabled,
|
||||
mutation_audit(
|
||||
@@ -739,7 +736,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
self.control_plane
|
||||
.delete_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
command.id.as_str(),
|
||||
mutation_audit(
|
||||
context,
|
||||
|
||||
@@ -74,27 +74,23 @@ pub trait ControlPlaneRepository: Send + Sync {
|
||||
|
||||
async fn replace_control_plane(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
replacement: ControlPlaneReplacement,
|
||||
) -> StoreResult<ControlPlaneSnapshot>;
|
||||
|
||||
async fn create_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: NewClientApiKey,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn update_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: UpdateClientApiKeyDetails,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn set_client_api_key_enabled(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit: AdminAuditEvent,
|
||||
@@ -102,7 +98,6 @@ pub trait ControlPlaneRepository: Send + Sync {
|
||||
|
||||
async fn delete_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
@@ -142,7 +137,6 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn replace_control_plane(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
replacement: ControlPlaneReplacement,
|
||||
) -> StoreResult<ControlPlaneSnapshot> {
|
||||
replacement.settings.validate()?;
|
||||
@@ -152,12 +146,9 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin control plane replacement"))?;
|
||||
let result = async {
|
||||
let revision = update_runtime_settings_in_transaction(
|
||||
&mut transaction,
|
||||
expected_revision,
|
||||
&replacement.settings,
|
||||
)
|
||||
.await?;
|
||||
let revision =
|
||||
update_runtime_settings_in_transaction(&mut transaction, &replacement.settings)
|
||||
.await?;
|
||||
append_admin_audit_event_in_transaction(&mut transaction, replacement.audit, revision)
|
||||
.await?;
|
||||
load_control_plane_in_transaction(&mut transaction).await
|
||||
@@ -183,41 +174,29 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn create_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: NewClientApiKey,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::CreateClientApiKey(key),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
self.apply_targeted_mutation(ControlPlaneMutation::CreateClientApiKey(key), audit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: UpdateClientApiKeyDetails,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::UpdateClientApiKey(key),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
self.apply_targeted_mutation(ControlPlaneMutation::UpdateClientApiKey(key), audit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_client_api_key_enabled(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::SetClientApiKeyEnabled {
|
||||
id: id.to_owned(),
|
||||
enabled,
|
||||
@@ -229,12 +208,10 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn delete_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::DeleteClientApiKey(id.to_owned()),
|
||||
audit,
|
||||
)
|
||||
@@ -252,7 +229,6 @@ enum ControlPlaneMutation {
|
||||
impl PgControlPlaneRepository {
|
||||
async fn apply_targeted_mutation(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
mutation: ControlPlaneMutation,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
@@ -262,8 +238,7 @@ impl PgControlPlaneRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin targeted control plane mutation"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_revision).await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
match mutation {
|
||||
ControlPlaneMutation::CreateClientApiKey(key) => {
|
||||
insert_client_api_key_in_transaction(&mut transaction, &key).await?;
|
||||
|
||||
@@ -485,25 +485,21 @@ pub trait ProviderAccountAdminRepository: Send + Sync {
|
||||
|
||||
async fn import_provider_accounts(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: ImportProviderAccounts,
|
||||
) -> StoreResult<ProviderAccountAdminImport>;
|
||||
|
||||
async fn rotate_provider_account(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: RotateProviderAccount,
|
||||
) -> StoreResult<ProviderAccountAdminRotation>;
|
||||
|
||||
async fn set_provider_account_enabled_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: SetProviderAccountEnabled,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn delete_provider_accounts_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: DeleteProviderAccounts,
|
||||
) -> StoreResult<Revision>;
|
||||
}
|
||||
@@ -767,7 +763,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn import_provider_accounts(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: ImportProviderAccounts,
|
||||
) -> StoreResult<ProviderAccountAdminImport> {
|
||||
command.validate()?;
|
||||
@@ -777,9 +772,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
let mut account_ids = Vec::with_capacity(command.accounts.len());
|
||||
for account in &command.accounts {
|
||||
account_ids
|
||||
@@ -798,7 +791,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn rotate_provider_account(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: RotateProviderAccount,
|
||||
) -> StoreResult<ProviderAccountAdminRotation> {
|
||||
command.scope.validate()?;
|
||||
@@ -814,9 +806,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin rotation"))?;
|
||||
let result = async {
|
||||
let config_revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let config_revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
let credential_revision = rotate_provider_account_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -841,7 +831,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn set_provider_account_enabled_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: SetProviderAccountEnabled,
|
||||
) -> StoreResult<Revision> {
|
||||
command.scope.validate()?;
|
||||
@@ -852,9 +841,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin state change"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
set_provider_account_enabled_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -872,7 +859,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn delete_provider_accounts_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: DeleteProviderAccounts,
|
||||
) -> StoreResult<Revision> {
|
||||
command.scope.validate()?;
|
||||
@@ -883,9 +869,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin deletion"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
delete_provider_accounts_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -1032,7 +1016,6 @@ impl PgAdminAccountStore {
|
||||
|
||||
async fn commit_prepared_import(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
prepared: PreparedCredentialImport,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
@@ -1046,22 +1029,19 @@ impl PgAdminAccountStore {
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
let imported = self
|
||||
.accounts
|
||||
.import_provider_accounts(
|
||||
store_revision(expected_config_revision)?,
|
||||
ImportProviderAccounts {
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: provider_kind.clone(),
|
||||
},
|
||||
accounts,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&provider_kind,
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: provider_kind.clone(),
|
||||
},
|
||||
)
|
||||
accounts,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&provider_kind,
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
Ok(CredentialImportResult {
|
||||
@@ -1083,7 +1063,6 @@ impl PgAdminAccountStore {
|
||||
|
||||
async fn commit_prepared_rotation(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
prepared: PreparedCredentialRotationFacts,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
@@ -1094,36 +1073,31 @@ impl PgAdminAccountStore {
|
||||
};
|
||||
let rotation = self
|
||||
.accounts
|
||||
.rotate_provider_account(
|
||||
store_revision(expected_config_revision)?,
|
||||
RotateProviderAccount {
|
||||
scope,
|
||||
profile: UpdateProviderAccount {
|
||||
id: account_id.as_str().to_owned(),
|
||||
name: prepared.name,
|
||||
email: prepared.email,
|
||||
plan_type: prepared.plan_type,
|
||||
},
|
||||
credential: ProviderCredentialUpdate {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
expected_revision: store_revision(prepared.expected_credential_revision)?,
|
||||
provider_credentials_json: provider_document_json(
|
||||
prepared.provider_material,
|
||||
)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?,
|
||||
has_refresh_token: prepared.has_refresh_token,
|
||||
access_token_expires_at: prepared.access_token_expires_at,
|
||||
next_refresh_at: prepared.next_refresh_at,
|
||||
},
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
account_id.as_str(),
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope,
|
||||
profile: UpdateProviderAccount {
|
||||
id: account_id.as_str().to_owned(),
|
||||
name: prepared.name,
|
||||
email: prepared.email,
|
||||
plan_type: prepared.plan_type,
|
||||
},
|
||||
)
|
||||
credential: ProviderCredentialUpdate {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
expected_revision: store_revision(prepared.expected_credential_revision)?,
|
||||
provider_credentials_json: provider_document_json(prepared.provider_material)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?,
|
||||
has_refresh_token: prepared.has_refresh_token,
|
||||
access_token_expires_at: prepared.access_token_expires_at,
|
||||
next_refresh_at: prepared.next_refresh_at,
|
||||
},
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
account_id.as_str(),
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
Ok(CredentialMutationResult {
|
||||
@@ -1383,13 +1357,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialImportCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
self.commit_prepared_import(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"import_document",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_import(command.prepared, context, "import_document")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
@@ -1397,13 +1366,11 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
let expected_config_revision = command.pending.expected_config_revision();
|
||||
match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => {
|
||||
let account_id = credential.account_id.clone();
|
||||
let result = self
|
||||
.commit_prepared_import(
|
||||
expected_config_revision,
|
||||
PreparedCredentialImport {
|
||||
provider_kind: credential.provider_kind.clone(),
|
||||
credentials: vec![credential],
|
||||
@@ -1431,13 +1398,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
})
|
||||
}
|
||||
AuthorizationCredentialCommit::Reauthorize(prepared) => {
|
||||
self.commit_prepared_rotation(
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
context,
|
||||
"reauthorize",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(prepared, context, "reauthorize")
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1447,13 +1409,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.commit_prepared_rotation(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"rotate_credential",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(command.prepared, context, "rotate_credential")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
@@ -1461,13 +1418,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.commit_prepared_rotation(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"refresh_credential",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(command.prepared, context, "refresh_credential")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
@@ -1478,21 +1430,18 @@ impl AccountStore for PgAdminAccountStore {
|
||||
let scope = self.required_scope(&command.account_id).await?;
|
||||
let action = if command.enabled { "enable" } else { "disable" };
|
||||
self.accounts
|
||||
.set_provider_account_enabled_admin(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
SetProviderAccountEnabled {
|
||||
scope,
|
||||
account_id: command.account_id.clone(),
|
||||
enabled: command.enabled,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&command.account_id,
|
||||
vec!["enabled".to_owned()],
|
||||
),
|
||||
},
|
||||
)
|
||||
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
|
||||
scope,
|
||||
account_id: command.account_id.clone(),
|
||||
enabled: command.enabled,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&command.account_id,
|
||||
vec!["enabled".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))
|
||||
.and_then(admin_revision)
|
||||
@@ -1517,20 +1466,17 @@ impl AccountStore for PgAdminAccountStore {
|
||||
"provider_accounts".to_owned()
|
||||
};
|
||||
self.accounts
|
||||
.delete_provider_accounts_admin(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: command.account_ids,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
"delete",
|
||||
"provider_account",
|
||||
&audit_target,
|
||||
Vec::new(),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete_provider_accounts_admin(DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: command.account_ids,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
"delete",
|
||||
"provider_account",
|
||||
&audit_target,
|
||||
Vec::new(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))
|
||||
.and_then(admin_revision)
|
||||
@@ -1661,6 +1607,7 @@ fn admin_account_status(
|
||||
} else if account.availability == ProviderAccountAvailability::QuotaExhausted {
|
||||
AdminAccountStatus::QuotaExhausted
|
||||
} else if account.availability == ProviderAccountAvailability::Expired
|
||||
|| account.availability == ProviderAccountAvailability::Invalid
|
||||
|| account
|
||||
.access_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
|
||||
@@ -12,7 +12,7 @@ use gateway_core::provider_ports::{
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
|
||||
};
|
||||
|
||||
use crate::{ConflictKind, Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
@@ -111,11 +111,8 @@ impl RuntimeSettingsUpdate {
|
||||
pub trait RuntimeSettingsRepository: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> StoreResult<RuntimeSettings>;
|
||||
|
||||
async fn update_runtime_settings(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
update: RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision>;
|
||||
async fn update_runtime_settings(&self, update: RuntimeSettingsUpdate)
|
||||
-> StoreResult<Revision>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -169,7 +166,6 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
|
||||
|
||||
async fn update_runtime_settings(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
update: RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision> {
|
||||
let mut transaction = self
|
||||
@@ -177,9 +173,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin runtime settings update"))?;
|
||||
let revision =
|
||||
update_runtime_settings_in_transaction(&mut transaction, expected_revision, &update)
|
||||
.await?;
|
||||
let revision = update_runtime_settings_in_transaction(&mut transaction, &update).await?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -228,28 +222,26 @@ pub(crate) async fn load_runtime_settings_in_transaction(
|
||||
|
||||
pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
expected_revision: Revision,
|
||||
update: &RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision> {
|
||||
update.validate()?;
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update runtime_settings
|
||||
set config_revision = config_revision + 1,
|
||||
admin_api_key = $2,
|
||||
refresh_margin_seconds = $3,
|
||||
refresh_concurrency = $4,
|
||||
max_concurrent_per_account = $5,
|
||||
request_interval_ms = $6,
|
||||
rotation_strategy = $7,
|
||||
model_mappings_json = $8,
|
||||
usage_retention_days = $9,
|
||||
ops_event_retention_days = $10,
|
||||
audit_retention_days = $11,
|
||||
updated_at = now()
|
||||
where id = 1 and config_revision = $1
|
||||
returning config_revision",
|
||||
admin_api_key = $1,
|
||||
refresh_margin_seconds = $2,
|
||||
refresh_concurrency = $3,
|
||||
max_concurrent_per_account = $4,
|
||||
request_interval_ms = $5,
|
||||
rotation_strategy = $6,
|
||||
model_mappings_json = $7,
|
||||
usage_retention_days = $8,
|
||||
ops_event_retention_days = $9,
|
||||
audit_retention_days = $10,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
)
|
||||
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
|
||||
.bind(update.admin_api_key.as_deref())
|
||||
.bind(i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?)
|
||||
.bind(i64::from(update.refresh_concurrency))
|
||||
@@ -263,32 +255,28 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
|
||||
.ok_or_else(|| StoreError::Conflict {
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
kind: ConflictKind::StaleRevision,
|
||||
})?;
|
||||
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
|
||||
}
|
||||
|
||||
pub(crate) async fn bump_config_revision_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
expected_revision: Revision,
|
||||
) -> StoreResult<Revision> {
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update runtime_settings
|
||||
set config_revision = config_revision + 1, updated_at = now()
|
||||
where id = 1 and config_revision = $1
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
)
|
||||
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("bump config revision in transaction"))?
|
||||
.ok_or_else(|| StoreError::Conflict {
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
kind: ConflictKind::StaleRevision,
|
||||
})?;
|
||||
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::{Duration, SystemTime};
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, PageSize, Revision as AdminRevision,
|
||||
MutationActor, MutationContext, PageSize,
|
||||
accounts::{
|
||||
AccountListQuery, AccountSort, AccountSortField, AccountStatus,
|
||||
AccountUsageWindowQuery, DeleteAccounts, SetAccountEnabled as AdminSetAccountEnabled,
|
||||
@@ -14,7 +14,7 @@ use gateway_admin::{
|
||||
CredentialAvailabilityFilter, CredentialListQuery, CredentialListWindow,
|
||||
},
|
||||
},
|
||||
ports::store::{AccountStore, AdminStoreErrorKind},
|
||||
ports::store::AccountStore,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, OpaqueProviderData,
|
||||
@@ -214,7 +214,7 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
assert_eq!(usage_page.config_revision.get(), 1);
|
||||
assert_eq!(usage_page.total, 5);
|
||||
assert_eq!(usage_page.summary.total, 5);
|
||||
assert_eq!(usage_page.summary.active, 3);
|
||||
assert_eq!(usage_page.summary.active, 2);
|
||||
assert_eq!(usage_page.summary.quota_exhausted, 1);
|
||||
assert_eq!(
|
||||
usage_page
|
||||
@@ -525,7 +525,6 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
let revision = store
|
||||
.set_account_enabled(
|
||||
AdminSetAccountEnabled {
|
||||
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
|
||||
account_id: "acct_terminal_mutation".to_owned(),
|
||||
enabled: false,
|
||||
},
|
||||
@@ -542,32 +541,9 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
.expect("read disabled state");
|
||||
assert!(!enabled);
|
||||
|
||||
let error = store
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: AdminRevision::new(1).expect("stale revision"),
|
||||
account_ids: vec!["acct_terminal_mutation".to_owned()],
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect_err("stale delete must roll back");
|
||||
assert_eq!(error.kind(), AdminStoreErrorKind::StaleRevision);
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
assert_eq!(
|
||||
account_count(&database.pool, "acct_terminal_mutation").await,
|
||||
1
|
||||
);
|
||||
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("count audit rows after rollback");
|
||||
assert_eq!(audit_count, 1);
|
||||
|
||||
let revision = store
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: revision,
|
||||
account_ids: vec!["acct_terminal_mutation".to_owned()],
|
||||
},
|
||||
&context,
|
||||
@@ -616,7 +592,6 @@ async fn terminal_admin_delete_removes_enabled_accounts_in_one_transaction() {
|
||||
let revision = PgAdminAccountStore::new(database.pool.clone())
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
|
||||
account_ids: vec![
|
||||
"acct_enabled_delete_a".to_owned(),
|
||||
"acct_enabled_delete_b".to_owned(),
|
||||
@@ -649,18 +624,14 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
provider_kind: "openai".to_owned(),
|
||||
};
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
Revision::new(1).expect("initial revision"),
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
|
||||
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
|
||||
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect("create imported account");
|
||||
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
|
||||
let revision = imported.config_revision;
|
||||
sqlx::query(
|
||||
"update provider_accounts
|
||||
set provider_quota_json = '{}'::jsonb, quota_observed_at = now(), updated_at = now()
|
||||
@@ -676,14 +647,11 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
updated.availability = ProviderAccountAvailability::Banned;
|
||||
updated.availability_reason = Some("upstream_account_deactivated".to_owned());
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![updated],
|
||||
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![updated],
|
||||
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect("update the same imported identity");
|
||||
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
|
||||
@@ -715,14 +683,11 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
assert_eq!(row.5.as_deref(), Some("upstream_account_deactivated"));
|
||||
|
||||
repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope,
|
||||
accounts: vec![account("acct_admin_upsert", "another-user")],
|
||||
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope,
|
||||
accounts: vec![account("acct_admin_upsert", "another-user")],
|
||||
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect_err("an existing account ID must not be rebound");
|
||||
assert_eq!(current_revision(&database.pool).await, 3);
|
||||
@@ -859,18 +824,14 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
cooldown_account.availability = ProviderAccountAvailability::Cooldown;
|
||||
cooldown_account.cooldown_until = Some(Utc::now() + TimeDelta::minutes(10));
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
Revision::new(1).expect("initial revision"),
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
|
||||
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
|
||||
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
|
||||
})
|
||||
.await
|
||||
.expect("import provider accounts");
|
||||
let revision = imported.config_revision;
|
||||
assert_eq!(revision.get(), 2);
|
||||
assert_eq!(imported.config_revision.get(), 2);
|
||||
let ready: (bool, String, Option<chrono::DateTime<Utc>>) = sqlx::query_as(
|
||||
"select enabled, availability, cooldown_until from provider_accounts where id = $1",
|
||||
)
|
||||
@@ -891,21 +852,18 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert!(cooldown.2.is_some());
|
||||
|
||||
repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_transient", "user-admin-transient"),
|
||||
account("acct_admin_a", "user-admin-duplicate"),
|
||||
],
|
||||
audit: audit(
|
||||
"audit_account_failed_batch",
|
||||
"import_batch",
|
||||
"provider_accounts",
|
||||
),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_transient", "user-admin-transient"),
|
||||
account("acct_admin_a", "user-admin-duplicate"),
|
||||
],
|
||||
audit: audit(
|
||||
"audit_account_failed_batch",
|
||||
"import_batch",
|
||||
"provider_accounts",
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect_err("duplicate batch row must roll back the entire import");
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
@@ -918,15 +876,12 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
provider_kind: "xai".to_owned(),
|
||||
};
|
||||
let wrong_scope_error = repository
|
||||
.rotate_provider_account(
|
||||
revision,
|
||||
RotateProviderAccount {
|
||||
scope: wrong_scope,
|
||||
profile: profile("acct_admin_a", "wrong scope"),
|
||||
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
|
||||
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope: wrong_scope,
|
||||
profile: profile("acct_admin_a", "wrong scope"),
|
||||
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
|
||||
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect_err("Provider endpoint must not rotate another Provider account");
|
||||
assert!(matches!(
|
||||
@@ -939,32 +894,27 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
|
||||
let rotation = repository
|
||||
.rotate_provider_account(
|
||||
revision,
|
||||
RotateProviderAccount {
|
||||
scope: scope.clone(),
|
||||
profile: profile("acct_admin_a", "rotated account"),
|
||||
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
|
||||
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope: scope.clone(),
|
||||
profile: profile("acct_admin_a", "rotated account"),
|
||||
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
|
||||
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect("rotate provider account");
|
||||
assert_eq!(rotation.config_revision.get(), 3);
|
||||
assert_eq!(rotation.credential_revision.get(), 2);
|
||||
|
||||
let revision = repository
|
||||
.set_provider_account_enabled_admin(
|
||||
rotation.config_revision,
|
||||
SetProviderAccountEnabled {
|
||||
scope: scope.clone(),
|
||||
account_id: "acct_admin_a".to_owned(),
|
||||
enabled: false,
|
||||
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
|
||||
scope: scope.clone(),
|
||||
account_id: "acct_admin_a".to_owned(),
|
||||
enabled: false,
|
||||
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect("disable provider account");
|
||||
assert_eq!(revision.get(), 4);
|
||||
let exports = repository
|
||||
.export_provider_accounts(
|
||||
scope.clone(),
|
||||
@@ -977,14 +927,11 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert!(!format!("{exports:?}").contains("rotated-secret"));
|
||||
|
||||
let revision = repository
|
||||
.delete_provider_accounts_admin(
|
||||
revision,
|
||||
DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
|
||||
audit: audit("audit_account_delete", "delete", "provider_accounts"),
|
||||
},
|
||||
)
|
||||
.delete_provider_accounts_admin(DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
|
||||
audit: audit("audit_account_delete", "delete", "provider_accounts"),
|
||||
})
|
||||
.await
|
||||
.expect("delete selected accounts regardless of enabled state");
|
||||
assert_eq!(revision.get(), 5);
|
||||
|
||||
@@ -942,7 +942,6 @@ struct PendingDocument {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PendingMutationDocument {
|
||||
schema_version: u64,
|
||||
expected_config_revision: u64,
|
||||
provider_kind: String,
|
||||
target: PendingTargetDocument,
|
||||
owner: PendingOwnerDocument,
|
||||
@@ -1025,10 +1024,6 @@ fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value
|
||||
"schema_version".to_owned(),
|
||||
Value::Number(Number::from(PENDING_DOCUMENT_SCHEMA_VERSION)),
|
||||
);
|
||||
document.insert(
|
||||
"expected_config_revision".to_owned(),
|
||||
Value::Number(Number::from(mutation.expected_config_revision().get())),
|
||||
);
|
||||
document.insert(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(mutation.provider_kind().as_str().to_owned()),
|
||||
@@ -1119,8 +1114,6 @@ fn decode_mutation(
|
||||
if document.schema_version != PENDING_DOCUMENT_SCHEMA_VERSION {
|
||||
return Err(CodexOAuthPendingStoreError::InvalidValue);
|
||||
}
|
||||
let expected_config_revision = Revision::new(document.expected_config_revision)
|
||||
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
|
||||
let provider_kind = ProviderKind::new(document.provider_kind)
|
||||
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
|
||||
let target = match document.target {
|
||||
@@ -1147,7 +1140,6 @@ fn decode_mutation(
|
||||
request_id: document.started_request_id,
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::credential::CodexQuotaRefreshPolicy;
|
||||
use crate::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
@@ -132,7 +132,22 @@ impl CodexApiConfig {
|
||||
fn validate(&self) -> Result<(), OpenAiConfigError> {
|
||||
let url = Url::parse(&self.base_url)
|
||||
.map_err(|_| OpenAiConfigError::InvalidField("openai.api.base_url"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
// 上游地址只接受 https;明文 http 仅放行本机回环(本地联调),
|
||||
// 避免把上游指向内网明文服务。凭据/查询串会污染端点拼接,一并拒绝。
|
||||
let is_loopback_host = match url.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(Host::Domain(_)) | None => false,
|
||||
};
|
||||
let is_loopback_http = url.scheme() == "http" && is_loopback_host;
|
||||
let is_secure_public = url.scheme() == "https" && url.host_str().is_some();
|
||||
if !(is_loopback_http || is_secure_public)
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(OpenAiConfigError::InvalidField("openai.api.base_url"));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -371,10 +371,12 @@ impl CodexCredentialCatalogService {
|
||||
union_order.push(entry.key().clone());
|
||||
entry.insert(model.clone());
|
||||
}
|
||||
Entry::Occupied(entry) if entry.get() == model => {}
|
||||
Entry::Occupied(_) => {
|
||||
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
|
||||
// 事实冲突才让整轮刷新失败。
|
||||
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
|
||||
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
|
||||
}
|
||||
Entry::Occupied(_) => {}
|
||||
}
|
||||
}
|
||||
account_models.insert(
|
||||
@@ -677,6 +679,13 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
|
||||
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
|
||||
/// 跨套餐漂移不构成冲突。
|
||||
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
|
||||
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
|
||||
}
|
||||
|
||||
fn catalog_candidates_by_scope(
|
||||
accounts: Vec<ProviderAccount>,
|
||||
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
|
||||
|
||||
@@ -532,59 +532,17 @@ impl CodexCredentialQuotaService {
|
||||
let observed_at = SystemTime::now();
|
||||
match self.fetch_usage_with_recovery(&client, &account).await {
|
||||
Ok(FetchedCodexQuota { account, value }) => {
|
||||
let fact = parse_codex_quota_usage(&value)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let outcome = self
|
||||
.store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(object)),
|
||||
observed_at: Some(observed_at),
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
summary.stale += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(current) = self.store.get_account(account.id()).await? else {
|
||||
summary.stale += 1;
|
||||
continue;
|
||||
};
|
||||
if current.revision() != account.revision() {
|
||||
summary.stale += 1;
|
||||
continue;
|
||||
}
|
||||
self.scheduling.observe(
|
||||
current.id().clone(),
|
||||
current.revision(),
|
||||
observed_at,
|
||||
fact,
|
||||
);
|
||||
if fact.exhausted() {
|
||||
summary.exhausted += 1;
|
||||
} else {
|
||||
summary.updated += 1;
|
||||
}
|
||||
if let Some(availability) = quota_success_availability(
|
||||
current.availability(),
|
||||
current.cooldown_until(),
|
||||
fact.exhausted(),
|
||||
SystemTime::now(),
|
||||
) {
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(
|
||||
¤t,
|
||||
availability,
|
||||
fact.exhausted().then_some("quota_exhausted".to_owned()),
|
||||
None,
|
||||
observed_at,
|
||||
)
|
||||
.await;
|
||||
// 单账号解析或落库失败只影响该账号;其余账号继续同步。
|
||||
if let Err(error) = self
|
||||
.apply_fetched_quota(&account, &value, observed_at, &mut summary)
|
||||
.await
|
||||
{
|
||||
summary.transient += 1;
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
error = %error,
|
||||
"OpenAI quota synchronization skipped one account"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(CodexQuotaFetchError::InvalidCredential) => {
|
||||
@@ -622,6 +580,67 @@ impl CodexCredentialQuotaService {
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
|
||||
async fn apply_fetched_quota(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
value: &Value,
|
||||
observed_at: SystemTime,
|
||||
summary: &mut CodexQuotaSyncSummary,
|
||||
) -> Result<(), CodexCredentialQuotaError> {
|
||||
let fact = parse_codex_quota_usage(value)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let outcome = self
|
||||
.store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(object)),
|
||||
observed_at: Some(observed_at),
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
summary.stale += 1;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(current) = self.store.get_account(account.id()).await? else {
|
||||
summary.stale += 1;
|
||||
return Ok(());
|
||||
};
|
||||
if current.revision() != account.revision() {
|
||||
summary.stale += 1;
|
||||
return Ok(());
|
||||
}
|
||||
self.scheduling
|
||||
.observe(current.id().clone(), current.revision(), observed_at, fact);
|
||||
if fact.exhausted() {
|
||||
summary.exhausted += 1;
|
||||
} else {
|
||||
summary.updated += 1;
|
||||
}
|
||||
if let Some(availability) = quota_success_availability(
|
||||
current.availability(),
|
||||
current.cooldown_until(),
|
||||
fact.exhausted(),
|
||||
SystemTime::now(),
|
||||
) {
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(
|
||||
¤t,
|
||||
availability,
|
||||
fact.exhausted().then_some("quota_exhausted".to_owned()),
|
||||
None,
|
||||
observed_at,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把正常推理响应携带的限流事实合并进 Provider 原始 quota JSON。
|
||||
pub async fn synchronize_passive_headers(
|
||||
&self,
|
||||
|
||||
@@ -27,7 +27,7 @@ use gateway_core::event::{
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
|
||||
use gateway_core::provider_ports::ProviderSessionAffinityKey;
|
||||
use gateway_core::routing::{ModelCapabilities, ProviderKind, UpstreamModelId};
|
||||
use gateway_core::routing::{ModelCapabilities, ModelPresentation, ProviderKind, UpstreamModelId};
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
|
||||
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
|
||||
@@ -50,7 +50,9 @@ use crate::credential::{
|
||||
use crate::transport::canonical::{
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
|
||||
};
|
||||
use crate::transport::catalog::{CodexCatalogCapabilityEvidence, CodexCatalogModel};
|
||||
use crate::transport::catalog::{
|
||||
CodexCatalogCapabilityEvidence, CodexCatalogModel, CodexCatalogVisibility,
|
||||
};
|
||||
use crate::transport::diagnostics::{
|
||||
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
|
||||
};
|
||||
@@ -1543,6 +1545,46 @@ fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabil
|
||||
}
|
||||
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
|
||||
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
|
||||
.with_presentation(codex_model_presentation(model))
|
||||
}
|
||||
|
||||
fn codex_model_presentation(model: &CodexCatalogModel) -> ModelPresentation {
|
||||
let capabilities = model.capabilities();
|
||||
let reasoning_efforts = capabilities.reasoning_efforts().to_vec();
|
||||
// Codex 目录不声明默认 effort;有 medium 时对齐官方 picker 缺省,否则取首项。
|
||||
let default_reasoning = reasoning_efforts
|
||||
.iter()
|
||||
.find(|effort| effort.as_str() == "medium")
|
||||
.or_else(|| reasoning_efforts.first())
|
||||
.cloned();
|
||||
let hidden = matches!(
|
||||
model.metadata().visibility(),
|
||||
Some(CodexCatalogVisibility::Hide | CodexCatalogVisibility::None)
|
||||
);
|
||||
|
||||
ModelPresentation::new(
|
||||
Some(model.display_name().to_owned()),
|
||||
model.metadata().description().map(str::to_owned),
|
||||
)
|
||||
.with_reasoning(default_reasoning, reasoning_efforts)
|
||||
.with_context_window_tokens(
|
||||
model
|
||||
.limits()
|
||||
.context_window_tokens()
|
||||
.map(std::num::NonZeroU64::get),
|
||||
)
|
||||
.with_image_input(capabilities.image_input() == CodexCatalogCapabilityEvidence::DeclaredNative)
|
||||
// Codex Responses 工具协议随 API 支持一并可用;只有明确不支持才关闭。
|
||||
.with_agent_tools(
|
||||
capabilities.responses_api() != CodexCatalogCapabilityEvidence::DeclaredUnsupported,
|
||||
capabilities.parallel_tool_calls() == CodexCatalogCapabilityEvidence::DeclaredNative,
|
||||
)
|
||||
.with_search_tool(capabilities.web_search() == CodexCatalogCapabilityEvidence::DeclaredNative)
|
||||
.with_image_detail_original(
|
||||
capabilities.image_detail_original() == CodexCatalogCapabilityEvidence::DeclaredNative,
|
||||
)
|
||||
.with_verbosity(capabilities.verbosity() == CodexCatalogCapabilityEvidence::DeclaredNative)
|
||||
.with_hidden(hidden)
|
||||
}
|
||||
|
||||
fn selected_transport(request: &CodexResponsesRequest) -> CodexProviderTransport {
|
||||
|
||||
@@ -273,7 +273,7 @@ impl CodexCanonicalDecoder {
|
||||
self.merge_timing_signals(signals);
|
||||
if matches!(event_type, Some("response.failed" | "error")) {
|
||||
let failure = ResponsesSseFailure::from_event(event_type.unwrap_or_default(), &value);
|
||||
if let Ok(wire) = Self::wire_for_event(event, value, raw_sse_frame) {
|
||||
if let Some(wire) = Self::wire_for_event(event, value, raw_sse_frame) {
|
||||
output.push(ProviderEvent::wire(wire));
|
||||
}
|
||||
return Err(CodexCanonicalError::Upstream(failure));
|
||||
@@ -282,7 +282,7 @@ impl CodexCanonicalDecoder {
|
||||
if let Some(event_type) = event_type {
|
||||
let _ = self.decode_event(event_type, &value, &mut canonical);
|
||||
}
|
||||
let Ok(wire) = Self::wire_for_event(event, value, raw_sse_frame) else {
|
||||
let Some(wire) = Self::wire_for_event(event, value, raw_sse_frame) else {
|
||||
return Ok(());
|
||||
};
|
||||
output.push(if canonical.is_empty() {
|
||||
@@ -301,28 +301,47 @@ impl CodexCanonicalDecoder {
|
||||
self.timing_signals.text_output |= signals.text_output;
|
||||
}
|
||||
|
||||
/// 构造下发的 wire event。
|
||||
///
|
||||
/// 上游事件名或 SSE id 不满足 wire 安全约束时剥离该元数据继续下发,
|
||||
/// 而不是把整帧静默丢弃;raw 帧存在时原始字节仍原样透传,JSON 正文
|
||||
/// 继续供旁路观测使用。
|
||||
fn wire_for_event(
|
||||
event: SseEvent,
|
||||
value: Value,
|
||||
raw_sse_frame: Option<Bytes>,
|
||||
) -> Result<ProtocolWireEvent, gateway_core::error::IdentifierError> {
|
||||
) -> Option<ProtocolWireEvent> {
|
||||
let event_type = event.event.filter(|name| {
|
||||
ProtocolWireEvent::json("openai", Some(name.clone()), Value::Null).is_ok()
|
||||
});
|
||||
let sse_id = event.id.filter(|id| {
|
||||
ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
None,
|
||||
Value::Null,
|
||||
Some(id.clone()),
|
||||
None,
|
||||
)
|
||||
.is_ok()
|
||||
});
|
||||
match raw_sse_frame {
|
||||
Some(raw_sse_frame) => ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
event.event,
|
||||
event_type,
|
||||
value,
|
||||
raw_sse_frame,
|
||||
event.id,
|
||||
sse_id,
|
||||
event.retry,
|
||||
),
|
||||
None => ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
event.event,
|
||||
event_type,
|
||||
value,
|
||||
event.id,
|
||||
sse_id,
|
||||
event.retry,
|
||||
),
|
||||
}
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn failure(
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::{
|
||||
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_protocol::openai::{
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, events::retry_after_seconds_from_body,
|
||||
@@ -491,11 +492,33 @@ pub(super) fn retry_after_seconds(headers: &HeaderMap, body: Option<&str>) -> Op
|
||||
headers
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.and_then(parse_retry_after)
|
||||
.or_else(|| body.and_then(retry_after_seconds_from_body))
|
||||
}
|
||||
|
||||
/// 解析 RFC 7231 `Retry-After` 值:delay-seconds 或 HTTP-date,统一换算为剩余秒数。
|
||||
pub(super) fn parse_retry_after(value: &str) -> Option<u64> {
|
||||
let value = value.trim();
|
||||
if let Ok(seconds) = value.parse::<u64>() {
|
||||
return (seconds > 0).then_some(seconds);
|
||||
}
|
||||
let remaining = parse_http_date(value)?.signed_duration_since(Utc::now());
|
||||
u64::try_from(remaining.num_seconds())
|
||||
.ok()
|
||||
.filter(|seconds| *seconds > 0)
|
||||
}
|
||||
|
||||
/// IMF-fixdate 优先;RFC 7231 要求接收方兼容过时的 RFC 850 与 asctime 形式。
|
||||
fn parse_http_date(value: &str) -> Option<DateTime<Utc>> {
|
||||
if let Ok(date) = DateTime::parse_from_rfc2822(value) {
|
||||
return Some(date.with_timezone(&Utc));
|
||||
}
|
||||
["%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"]
|
||||
.iter()
|
||||
.find_map(|format| NaiveDateTime::parse_from_str(value, format).ok())
|
||||
.map(|date| date.and_utc())
|
||||
}
|
||||
|
||||
pub(super) fn truncate_for_error(body: &str) -> String {
|
||||
body.chars().take(200).collect()
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ pub enum CodexUpstreamSendPhase {
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// Transport 边界对上游失败的账号级分类。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CodexFailureCategory {
|
||||
pub enum CodexFailureCategory {
|
||||
ModelUnsupported,
|
||||
CredentialExpired,
|
||||
IdentityVerificationRequired,
|
||||
@@ -99,7 +100,8 @@ pub(crate) enum CodexFailureCategory {
|
||||
Transport,
|
||||
}
|
||||
|
||||
pub(crate) struct CodexUpstreamFailure {
|
||||
/// 一次已分类的上游拒绝及其可持久化事实。
|
||||
pub struct CodexUpstreamFailure {
|
||||
pub(crate) status: Option<StatusCode>,
|
||||
pub(crate) code: Option<String>,
|
||||
pub(crate) client_message: Option<String>,
|
||||
@@ -115,7 +117,8 @@ pub(crate) struct CodexUpstreamFailure {
|
||||
}
|
||||
|
||||
impl CodexUpstreamFailure {
|
||||
pub(crate) fn from_response(
|
||||
/// 用完整 HTTP 响应事实构造并分类一次上游拒绝。
|
||||
pub fn from_response(
|
||||
status: StatusCode,
|
||||
body: &str,
|
||||
retry_after_seconds: Option<u64>,
|
||||
@@ -187,11 +190,15 @@ impl CodexUpstreamFailure {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn category(&self) -> CodexFailureCategory {
|
||||
/// 返回账号级失败分类。
|
||||
#[must_use]
|
||||
pub const fn category(&self) -> CodexFailureCategory {
|
||||
self.category
|
||||
}
|
||||
|
||||
pub(crate) const fn replay_is_safe(&self) -> bool {
|
||||
/// 返回该拒绝是否允许换号重放。
|
||||
#[must_use]
|
||||
pub const fn replay_is_safe(&self) -> bool {
|
||||
match self.send_phase {
|
||||
CodexUpstreamSendPhase::BeforePayload => true,
|
||||
CodexUpstreamSendPhase::Ambiguous => false,
|
||||
@@ -379,8 +386,26 @@ fn classify_upstream_failure(
|
||||
if identity_signals.into_iter().any(is_banned_account) {
|
||||
return CodexFailureCategory::Banned;
|
||||
}
|
||||
if identity_signals.into_iter().any(is_expired_credential)
|
||||
// message/body 是自由文本,正文里偶然出现 "unauthorized" 不能证明凭证过期;
|
||||
// 只有结构化 code/type、身份错误头,或配合 401/403 状态的文本才算数。
|
||||
let structured_auth_signals = [
|
||||
identity_code.as_str(),
|
||||
identity_authorization.as_str(),
|
||||
code.as_str(),
|
||||
error_type.as_str(),
|
||||
];
|
||||
let auth_status = matches!(
|
||||
status,
|
||||
Some(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|
||||
);
|
||||
if structured_auth_signals
|
||||
.into_iter()
|
||||
.any(is_expired_credential)
|
||||
|| status == Some(StatusCode::UNAUTHORIZED)
|
||||
|| (auth_status
|
||||
&& [message.as_str(), body.as_str()]
|
||||
.into_iter()
|
||||
.any(is_expired_credential))
|
||||
{
|
||||
return CodexFailureCategory::CredentialExpired;
|
||||
}
|
||||
|
||||
@@ -473,57 +473,35 @@ fn replace_metadata_field(metadata: &mut Map<String, Value>, key: &str, value: O
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移契约是 header-authoritative:连接边界解析出的协议上下文(官方请求头)
|
||||
// 优先;body 顶层别名只在 header 缺失时兜底(例如 WebSocket 帧无法逐轮携带
|
||||
// 请求头的场景)。
|
||||
fn apply_protocol_context(request: &mut CodexResponsesRequest, context: &Map<String, Value>) {
|
||||
request.turn_state = request
|
||||
.turn_state
|
||||
.take()
|
||||
.or_else(|| context_string(context, "turn_state"));
|
||||
request.turn_metadata = request
|
||||
.turn_metadata
|
||||
.take()
|
||||
.or_else(|| context_string(context, "turn_metadata"));
|
||||
request.beta_features = request
|
||||
.beta_features
|
||||
.take()
|
||||
.or_else(|| context_string(context, "beta_features"));
|
||||
request.version = request
|
||||
.version
|
||||
.take()
|
||||
.or_else(|| context_string(context, "version"));
|
||||
request.include_timing_metrics = request
|
||||
.include_timing_metrics
|
||||
.take()
|
||||
.or_else(|| context_string(context, "include_timing_metrics"));
|
||||
request.codex_window_id = request
|
||||
.codex_window_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "codex_window_id"));
|
||||
request.parent_thread_id = request
|
||||
.parent_thread_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "parent_thread_id"));
|
||||
request.turn_state =
|
||||
context_string(context, "turn_state").or_else(|| request.turn_state.take());
|
||||
request.turn_metadata =
|
||||
context_string(context, "turn_metadata").or_else(|| request.turn_metadata.take());
|
||||
request.beta_features =
|
||||
context_string(context, "beta_features").or_else(|| request.beta_features.take());
|
||||
request.version = context_string(context, "version").or_else(|| request.version.take());
|
||||
request.include_timing_metrics = context_string(context, "include_timing_metrics")
|
||||
.or_else(|| request.include_timing_metrics.take());
|
||||
request.codex_window_id =
|
||||
context_string(context, "codex_window_id").or_else(|| request.codex_window_id.take());
|
||||
request.parent_thread_id =
|
||||
context_string(context, "parent_thread_id").or_else(|| request.parent_thread_id.take());
|
||||
let prompt_cache_key = request.prompt_cache_key().map(ToOwned::to_owned);
|
||||
request.client_conversation_id = request
|
||||
.client_conversation_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "conversation_id"))
|
||||
request.client_conversation_id = context_string(context, "conversation_id")
|
||||
.or_else(|| request.client_conversation_id.take())
|
||||
.or(prompt_cache_key);
|
||||
request.client_session_id = request
|
||||
.client_session_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "session_id"));
|
||||
request.client_thread_id = request
|
||||
.client_thread_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "thread_id"));
|
||||
request.client_request_id = request
|
||||
.client_request_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "client_request_id"));
|
||||
request.client_turn_id = request
|
||||
.client_turn_id
|
||||
.take()
|
||||
.or_else(|| context_string(context, "turn_id"));
|
||||
request.client_session_id =
|
||||
context_string(context, "session_id").or_else(|| request.client_session_id.take());
|
||||
request.client_thread_id =
|
||||
context_string(context, "thread_id").or_else(|| request.client_thread_id.take());
|
||||
request.client_request_id =
|
||||
context_string(context, "client_request_id").or_else(|| request.client_request_id.take());
|
||||
request.client_turn_id =
|
||||
context_string(context, "turn_id").or_else(|| request.client_turn_id.take());
|
||||
request.responses_lite =
|
||||
context_string(context, "responses_lite").or_else(|| request.responses_lite.take());
|
||||
request.memgen_request =
|
||||
|
||||
@@ -18,7 +18,8 @@ use crate::{
|
||||
responses::CodexResponsesRequest, websocket::websocket_response_create_payload_text,
|
||||
},
|
||||
transport::{
|
||||
diagnostics::CodexUpstreamSendPhase, endpoints::CODEX_RESPONSES_PATH, response_meta, tls,
|
||||
client::parse_retry_after, diagnostics::CodexUpstreamSendPhase,
|
||||
endpoints::CODEX_RESPONSES_PATH, response_meta, tls,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -185,8 +186,7 @@ fn websocket_opening_error(response: &WsResponse<Option<Vec<u8>>>) -> CodexWebSo
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.and_then(parse_retry_after)
|
||||
.or_else(|| events::retry_after_seconds_from_body(&body));
|
||||
CodexWebSocketExchangeError::upstream(
|
||||
status_code,
|
||||
|
||||
@@ -329,7 +329,6 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
let started = bundle
|
||||
.admin_provider()
|
||||
.start_authorization(PendingAuthorizationMutation::new(
|
||||
Revision::new(7).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "OAuth account".to_owned(),
|
||||
@@ -346,12 +345,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
.get("mutation")
|
||||
.and_then(Value::as_object)
|
||||
.expect("pending mutation");
|
||||
assert_eq!(
|
||||
mutation
|
||||
.get("expected_config_revision")
|
||||
.and_then(Value::as_u64),
|
||||
Some(7)
|
||||
);
|
||||
assert!(mutation.get("expected_config_revision").is_none());
|
||||
assert_eq!(
|
||||
mutation.get("started_request_id").and_then(Value::as_str),
|
||||
Some("request-start")
|
||||
|
||||
@@ -45,6 +45,43 @@ fn openai_config_rejects_noncanonical_versions_and_empty_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_restricts_upstream_base_url_to_https_or_loopback_http() {
|
||||
for base_url in [
|
||||
"http://internal.example.com/backend-api",
|
||||
"http://10.0.0.7/backend-api",
|
||||
"https://chatgpt.com/backend-api?debug=1",
|
||||
"https://user:pass@chatgpt.com/backend-api",
|
||||
"https://chatgpt.com/backend-api#fragment",
|
||||
"ftp://chatgpt.com/backend-api",
|
||||
] {
|
||||
let mut config = valid_config();
|
||||
config.api.base_url = base_url.to_owned();
|
||||
assert!(
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.is_err(),
|
||||
"expected {base_url} to be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
for base_url in [
|
||||
"https://chatgpt.com/backend-api",
|
||||
"http://127.0.0.1:8080/backend-api",
|
||||
"http://localhost:8080/backend-api",
|
||||
"http://[::1]:8080/backend-api",
|
||||
] {
|
||||
let mut config = valid_config();
|
||||
config.api.base_url = base_url.to_owned();
|
||||
assert!(
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.is_ok(),
|
||||
"expected {base_url} to be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_keeps_the_quota_exhaustion_scheduling_switch() {
|
||||
let mut config = valid_config();
|
||||
|
||||
@@ -35,6 +35,21 @@ impl Respond for ReplacingCatalogResponder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按调用顺序为不同套餐 scope 返回不同的目录正文。
|
||||
struct SequencedCatalogResponder {
|
||||
calls: Arc<AtomicUsize>,
|
||||
bodies: [&'static [u8]; 2],
|
||||
}
|
||||
|
||||
impl Respond for SequencedCatalogResponder {
|
||||
fn respond(&self, _: &Request) -> ResponseTemplate {
|
||||
let index = self.calls.fetch_add(1, Ordering::SeqCst).min(1);
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_raw(self.bodies[index].to_vec(), "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_profile() -> CodexWireProfileState {
|
||||
CodexWireProfileState::new(CodexWireProfile {
|
||||
originator: "codex_cli_rs".to_owned(),
|
||||
@@ -75,12 +90,22 @@ fn service_with_catalog_cache(
|
||||
}
|
||||
|
||||
async fn seed_account(store: &Arc<MemoryAccountStore>, account_id: &str) -> ProviderAccount {
|
||||
seed_account_with_plan(store, account_id, "pro").await
|
||||
}
|
||||
|
||||
async fn seed_account_with_plan(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
account_id: &str,
|
||||
plan: &str,
|
||||
) -> ProviderAccount {
|
||||
let mut verified_account = profile(&format!("chatgpt-{account_id}"));
|
||||
verified_account.plan_type = Some(plan.to_owned());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
name: account_id.to_owned(),
|
||||
secret: secret(&format!("access-{account_id}")),
|
||||
verified_account: profile(&format!("chatgpt-{account_id}")),
|
||||
verified_account,
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
@@ -194,6 +219,68 @@ async fn plan_catalog_refresh_stops_after_three_failed_accounts() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
seed_account_with_plan(&store, "acct_union_plus", "plus").await;
|
||||
seed_account_with_plan(&store, "acct_union_pro", "pro").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(SequencedCatalogResponder {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
bodies: [
|
||||
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4 Plus","description":"plus copy","supported_in_api":true}]}"#,
|
||||
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4 Pro","description":"pro copy","supported_in_api":true,"context_window":272000}]}"#,
|
||||
],
|
||||
})
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
|
||||
|
||||
let snapshot = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("presentation drift must not wedge the union");
|
||||
|
||||
assert_eq!(snapshot.models().len(), 1);
|
||||
assert_eq!(snapshot.models()[0].display_name(), "GPT-5.4 Plus");
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
|
||||
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(SequencedCatalogResponder {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
bodies: [
|
||||
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","supported_in_api":true}]}"#,
|
||||
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","supported_in_api":false}]}"#,
|
||||
],
|
||||
})
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
|
||||
|
||||
let error = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect_err("routing facts must still agree across plans");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CodexCredentialCatalogError::ConflictingModelFacts
|
||||
));
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_etag_change_is_deduplicated_and_queued_once() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -172,7 +172,6 @@ fn owner_context(request_id: &str) -> MutationContext {
|
||||
fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
|
||||
let context = owner_context(request_id);
|
||||
PendingAuthorizationMutation::new(
|
||||
Revision::new(1).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "OAuth Account".to_owned(),
|
||||
@@ -184,7 +183,6 @@ fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
|
||||
fn reauthorization_mutation(request_id: &str, account_id: &str) -> PendingAuthorizationMutation {
|
||||
let context = owner_context(request_id);
|
||||
PendingAuthorizationMutation::new(
|
||||
Revision::new(1).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
|
||||
@@ -14,6 +14,8 @@ use provider_openai::credential::{
|
||||
};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
|
||||
@@ -149,12 +151,20 @@ fn quota_service(store: &Arc<MemoryAccountStore>) -> CodexCredentialQuotaService
|
||||
fn quota_service_with_http(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
http: reqwest::Client,
|
||||
) -> CodexCredentialQuotaService {
|
||||
quota_service_with_base_url(store, http, OFFICIAL_CODEX_BASE_URL.to_owned())
|
||||
}
|
||||
|
||||
fn quota_service_with_base_url(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
) -> CodexCredentialQuotaService {
|
||||
CodexCredentialQuotaService::new(
|
||||
store.repository(),
|
||||
wire_profile(),
|
||||
http,
|
||||
OFFICIAL_CODEX_BASE_URL.to_owned(),
|
||||
base_url,
|
||||
crate::support::agent_identity_service(store),
|
||||
)
|
||||
}
|
||||
@@ -433,3 +443,72 @@ async fn periodic_quota_synchronization_throttles_the_same_exhausted_account() {
|
||||
|
||||
assert_eq!(summary, CodexQuotaSyncSummary::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_quota_json_for_one_account_does_not_abort_the_batch() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
for account_id in ["acct_quota_batch_bad", "acct_quota_batch_good"] {
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
reason: Some("quota_exhausted".to_owned()),
|
||||
cooldown_until: None,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
}
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header("authorization", "Bearer token-acct_quota_batch_bad"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"rate_limit": {"allowed": "not-a-boolean"}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
"Bearer token-acct_quota_batch_good",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {"used_percent": 25.0, "reset_at": 1_900_000_000}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let summary = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("batch must survive one invalid account");
|
||||
|
||||
assert_eq!(summary.updated, 1);
|
||||
assert_eq!(summary.transient, 1);
|
||||
let good = store
|
||||
.account("acct_quota_batch_good")
|
||||
.expect("good account");
|
||||
let snapshot = service
|
||||
.read_account(good.id())
|
||||
.await
|
||||
.expect("read good quota")
|
||||
.expect("good quota persisted");
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(75));
|
||||
}
|
||||
|
||||
@@ -25,12 +25,17 @@ use provider_openai::transport::CodexWebSocketPool;
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::{CodexProvider, OFFICIAL_CODEX_BASE_URL};
|
||||
use serde_json::{Map, json};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{
|
||||
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
|
||||
account_policy, agent_identity_service_with_pool, catalog_cache, profile, secret,
|
||||
};
|
||||
|
||||
const OFFICIAL_FIXTURE: &[u8] =
|
||||
include_bytes!("../transport/fixtures/official_models_snapshot.json");
|
||||
|
||||
fn wire_profile() -> CodexWireProfileState {
|
||||
CodexWireProfileState::new(CodexWireProfile {
|
||||
originator: "codex_cli_rs".to_owned(),
|
||||
@@ -52,6 +57,18 @@ fn provider(store: &Arc<MemoryAccountStore>) -> CodexProvider {
|
||||
fn provider_with_affinity(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
session_affinity: Arc<MemorySessionAffinity>,
|
||||
) -> CodexProvider {
|
||||
provider_with_affinity_and_base_url(store, session_affinity, OFFICIAL_CODEX_BASE_URL.to_owned())
|
||||
}
|
||||
|
||||
fn provider_with_base_url(store: &Arc<MemoryAccountStore>, base_url: String) -> CodexProvider {
|
||||
provider_with_affinity_and_base_url(store, Arc::new(MemorySessionAffinity::default()), base_url)
|
||||
}
|
||||
|
||||
fn provider_with_affinity_and_base_url(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
session_affinity: Arc<MemorySessionAffinity>,
|
||||
base_url: String,
|
||||
) -> CodexProvider {
|
||||
let profile = wire_profile();
|
||||
let http = reqwest::Client::builder()
|
||||
@@ -64,7 +81,7 @@ fn provider_with_affinity(
|
||||
store.repository(),
|
||||
profile.clone(),
|
||||
http.clone(),
|
||||
OFFICIAL_CODEX_BASE_URL.to_owned(),
|
||||
base_url.clone(),
|
||||
Arc::clone(&agent_identity),
|
||||
catalog_cache(),
|
||||
));
|
||||
@@ -72,7 +89,7 @@ fn provider_with_affinity(
|
||||
store.repository(),
|
||||
profile.clone(),
|
||||
http.clone(),
|
||||
OFFICIAL_CODEX_BASE_URL.to_owned(),
|
||||
base_url.clone(),
|
||||
Arc::clone(&agent_identity),
|
||||
));
|
||||
let account_feedback = Arc::new(AccountFeedbackStats::default());
|
||||
@@ -98,7 +115,7 @@ fn provider_with_affinity(
|
||||
account_feedback,
|
||||
http,
|
||||
profile,
|
||||
OFFICIAL_CODEX_BASE_URL.to_owned(),
|
||||
base_url,
|
||||
websocket_pool,
|
||||
)
|
||||
.expect("official OpenAI provider")
|
||||
@@ -362,3 +379,46 @@ fn request_observation_preserves_the_raw_reasoning_effort() {
|
||||
Some("future-value")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_compiles_catalog_presentation_for_codex_models() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_presentation").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_raw(OFFICIAL_FIXTURE.to_vec(), "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = provider_with_base_url(&store, server.uri());
|
||||
|
||||
let capabilities = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("capabilities");
|
||||
|
||||
assert_eq!(capabilities.len(), 1);
|
||||
assert_eq!(capabilities[0].upstream_model().as_str(), "gpt-5.4");
|
||||
let presentation = capabilities[0]
|
||||
.presentation()
|
||||
.expect("Codex model presentation");
|
||||
assert_eq!(presentation.display_name(), Some("GPT-5.4"));
|
||||
assert_eq!(
|
||||
presentation.description(),
|
||||
Some("Frontier agentic coding model.")
|
||||
);
|
||||
assert_eq!(presentation.supported_reasoning_efforts(), ["low", "high"]);
|
||||
assert_eq!(presentation.default_reasoning_effort(), Some("low"));
|
||||
assert_eq!(presentation.context_window_tokens(), Some(272_000));
|
||||
assert!(presentation.image_input());
|
||||
assert!(presentation.agent_tools());
|
||||
assert!(presentation.parallel_tool_calls());
|
||||
assert!(presentation.search_tool());
|
||||
assert!(presentation.image_detail_original());
|
||||
assert!(presentation.verbosity());
|
||||
assert!(!presentation.hidden());
|
||||
}
|
||||
|
||||
@@ -128,6 +128,44 @@ fn raw_sse_passthrough_should_forward_unparseable_frames_without_failure() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_sse_passthrough_should_forward_frames_with_unsafe_event_metadata() {
|
||||
// 上游事件名超出 wire 安全上限(256 字节)时剥离该元数据继续下发,
|
||||
// 原始帧字节仍原样透传,而不是整帧静默丢弃。
|
||||
let event_name = "x".repeat(300);
|
||||
let frame = format!("event: {event_name}\ndata: {{\"type\":\"noise\",\"marker\":1}}\n\n");
|
||||
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_raw_sse_passthrough()
|
||||
.push(frame.as_bytes())
|
||||
.expect("unsafe event metadata must not abort transparent transport");
|
||||
let wire = events[0].wire_event().expect("wire event");
|
||||
|
||||
assert_eq!(
|
||||
wire.raw_sse_frame().map(AsRef::as_ref),
|
||||
Some(frame.as_bytes())
|
||||
);
|
||||
assert!(wire.has_json_data());
|
||||
assert_eq!(wire.event_type(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_strip_unsafe_event_metadata_instead_of_dropping_the_event() {
|
||||
// 无 raw 帧的路径(WebSocket JSON 投影)同样不允许因元数据违规丢事件:
|
||||
// 剥离事件名后 JSON 正文仍完整下发。
|
||||
let event_name = "y".repeat(300);
|
||||
let frame = format!("event: {event_name}\ndata: {{\"type\":\"noise\",\"marker\":1}}\n\n");
|
||||
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.push(frame.as_bytes())
|
||||
.expect("unsafe event metadata must not drop the event");
|
||||
let wire = events[0].wire_event().expect("wire event");
|
||||
|
||||
assert_eq!(wire.event_type(), None);
|
||||
assert!(wire.has_json_data());
|
||||
assert_eq!(wire.data()["marker"], serde_json::json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use provider_openai::transport::CodexUpstreamDiagnostics;
|
||||
use provider_openai::transport::diagnostics::{
|
||||
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
fn classify(status: u16, body: &str) -> CodexFailureCategory {
|
||||
let status = reqwest::StatusCode::from_u16(status).expect("status code");
|
||||
CodexUpstreamFailure::from_response(
|
||||
status,
|
||||
body,
|
||||
None,
|
||||
&CodexUpstreamDiagnostics::with_status(status.as_u16()),
|
||||
&[],
|
||||
&[],
|
||||
CodexUpstreamSendPhase::AfterPayload,
|
||||
)
|
||||
.category()
|
||||
}
|
||||
|
||||
fn trace_header<'a>(diagnostics: &'a CodexUpstreamDiagnostics, name: &str) -> Option<&'a str> {
|
||||
diagnostics
|
||||
.trace_headers
|
||||
@@ -88,3 +105,41 @@ fn diagnostics_should_ignore_malformed_identity_error_json() {
|
||||
|
||||
assert_eq!(diagnostics.identity_error_code, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_substring_in_a_server_error_body_is_not_credential_expiry() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
500,
|
||||
r#"{"error":{"message":"upstream proxy replied: unauthorized"}}"#
|
||||
),
|
||||
CodexFailureCategory::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_401_classifies_as_credential_expiry_without_body_evidence() {
|
||||
assert_eq!(classify(401, ""), CodexFailureCategory::CredentialExpired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_text_with_http_403_still_classifies_as_credential_expiry() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
403,
|
||||
r#"{"error":{"message":"unauthorized: token expired"}}"#
|
||||
),
|
||||
CodexFailureCategory::CredentialExpired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_expiry_code_classifies_without_an_auth_status() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
500,
|
||||
r#"{"error":{"code":"token_expired","message":"internal"}}"#
|
||||
),
|
||||
CodexFailureCategory::CredentialExpired
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,6 +207,65 @@ fn encoder_should_ignore_legacy_context_aliases_and_metadata_fallbacks() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_context_should_win_over_body_topline_aliases() {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("prompt")),
|
||||
("turnState".to_owned(), json!("body-turn-state")),
|
||||
("turnMetadata".to_owned(), json!("body-turn-metadata")),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([
|
||||
(
|
||||
"turn_state".to_owned(),
|
||||
Value::String("header-turn-state".to_owned()),
|
||||
),
|
||||
(
|
||||
"turn_metadata".to_owned(),
|
||||
Value::String("header-turn-metadata".to_owned()),
|
||||
),
|
||||
]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
|
||||
assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state"));
|
||||
assert_eq!(
|
||||
encoded.turn_metadata.as_deref(),
|
||||
Some("header-turn-metadata")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_topline_alias_should_only_fill_an_absent_header_context() {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("prompt")),
|
||||
("turnState".to_owned(), json!("body-turn-state")),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([(
|
||||
"turn_metadata".to_owned(),
|
||||
Value::String("header-turn-metadata".to_owned()),
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
|
||||
assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state"));
|
||||
assert_eq!(
|
||||
encoded.turn_metadata.as_deref(),
|
||||
Some("header-turn-metadata")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
|
||||
@@ -220,6 +220,79 @@ async fn content_length_over_limit_error_body_should_keep_only_safe_metadata() {
|
||||
assert_oversized_error(error, StatusCode::TOO_MANY_REQUESTS, Some(7));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_http_date_should_be_converted_to_remaining_seconds() {
|
||||
let server = MockServer::start().await;
|
||||
let retry_at = (Utc::now() + chrono::TimeDelta::seconds(90))
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.insert_header("retry-after", retry_at.as_str())
|
||||
.set_body_raw(
|
||||
r#"{"error":{"message":"rate limited"}}"#,
|
||||
"application/json",
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let error = client(&server.uri())
|
||||
.fetch_usage(context())
|
||||
.await
|
||||
.expect_err("http-date rate limit");
|
||||
|
||||
let CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
panic!("expected an upstream rate-limit error");
|
||||
};
|
||||
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
let seconds = retry_after_seconds.expect("http-date retry-after should yield seconds");
|
||||
assert!(
|
||||
(1..=90).contains(&seconds),
|
||||
"expected remaining seconds within 90s, got {seconds}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_http_date_in_the_past_should_be_ignored() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.insert_header("retry-after", "Mon, 01 Jan 2001 00:00:00 GMT")
|
||||
.set_body_raw(
|
||||
r#"{"error":{"message":"rate limited"}}"#,
|
||||
"application/json",
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let error = client(&server.uri())
|
||||
.fetch_usage(context())
|
||||
.await
|
||||
.expect_err("http-date rate limit");
|
||||
|
||||
let CodexClientError::Upstream {
|
||||
retry_after_seconds,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
panic!("expected an upstream rate-limit error");
|
||||
};
|
||||
assert_eq!(retry_after_seconds, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_body_over_limit_should_be_rejected_without_content_length() {
|
||||
let (base_url, server) = spawn_chunked_server(usage_body(MAX_CODEX_USAGE_BODY_BYTES + 1));
|
||||
|
||||
@@ -423,6 +423,51 @@ async fn websocket_execute_response_create_request_should_preserve_opening_error
|
||||
assert!(body.contains("rate limited"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_opening_error_should_convert_http_date_retry_after_to_seconds() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let retry_at = (chrono::Utc::now() + chrono::TimeDelta::seconds(120))
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let _request = read_http_request(&mut stream).await;
|
||||
let body = r#"{"error":{"message":"rate limited"}}"#;
|
||||
stream
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 429 Too Many Requests\r\nretry-after: {retry_at}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let prepared = prepared_websocket_request(&format!("http://{addr}"));
|
||||
|
||||
let error = execute_response_create_request(&prepared)
|
||||
.await
|
||||
.expect_err("failed opening should surface upstream status");
|
||||
server.await.unwrap();
|
||||
|
||||
let CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
panic!("expected upstream opening error");
|
||||
};
|
||||
assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
let seconds = retry_after_seconds.expect("http-date retry-after should yield seconds");
|
||||
assert!(
|
||||
(1..=120).contains(&seconds),
|
||||
"expected remaining seconds within 120s, got {seconds}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_execute_response_create_request_should_reject_binary_event() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
@@ -1071,7 +1071,6 @@ struct PendingDocument {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PendingMutationDocument {
|
||||
expected_config_revision: u64,
|
||||
provider_kind: String,
|
||||
target: PendingTargetDocument,
|
||||
owner: PendingOwnerDocument,
|
||||
@@ -1126,10 +1125,6 @@ fn encode_pending(pending: &StoredXaiAuthorization) -> Map<String, Value> {
|
||||
|
||||
fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value> {
|
||||
let mut document = Map::new();
|
||||
document.insert(
|
||||
"expected_config_revision".to_owned(),
|
||||
Value::Number(Number::from(mutation.expected_config_revision().get())),
|
||||
);
|
||||
document.insert(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(mutation.provider_kind().as_str().to_owned()),
|
||||
@@ -1256,8 +1251,6 @@ fn decode_mutation(
|
||||
request_id: document.started_request_id,
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
Revision::new(document.expected_config_revision)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
|
||||
@@ -15,7 +15,7 @@ use gateway_core::engine::provider::ProviderCatalogGeneration;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope,
|
||||
};
|
||||
use gateway_core::routing::{ConfigRevision, ProviderKind};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::repository::{GrokCredentialRepository, LoadedGrokCredential};
|
||||
@@ -796,12 +796,25 @@ impl GrokCredentialCatalogCache for GrokCatalogCache {
|
||||
&self,
|
||||
scope: &GrokCatalogScope,
|
||||
) -> Result<Option<GrokPlanCatalog>, GrokCatalogCacheError> {
|
||||
self.port
|
||||
let Some(document) = self
|
||||
.port
|
||||
.read(&self.key(scope))
|
||||
.await
|
||||
.map_err(|_| GrokCatalogCacheError::Unavailable)?
|
||||
.map(|document| Self::decode(scope, document))
|
||||
.transpose()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// cache 只保存可重建 TTL 数据:损坏条目按 miss 丢弃,由调用方实时重建。
|
||||
match Self::decode(scope, document) {
|
||||
Ok(catalog) => Ok(Some(catalog)),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
scope = scope.as_str(),
|
||||
"discarding corrupt xAI model catalog cache entry"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn observed_model_support(
|
||||
@@ -824,37 +837,6 @@ pub enum GrokCatalogCacheError {
|
||||
InvalidData,
|
||||
}
|
||||
|
||||
/// 一次 Provider 同步得到的套餐目录和严格模型并集。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GrokCredentialCatalogSnapshot {
|
||||
config_revision: ConfigRevision,
|
||||
observed_at: DateTime<Utc>,
|
||||
plans: Vec<GrokPlanCatalog>,
|
||||
models: Vec<GrokCatalogModel>,
|
||||
}
|
||||
|
||||
impl GrokCredentialCatalogSnapshot {
|
||||
#[must_use]
|
||||
pub const fn config_revision(&self) -> ConfigRevision {
|
||||
self.config_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn observed_at(&self) -> DateTime<Utc> {
|
||||
self.observed_at
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plans(&self) -> &[GrokPlanCatalog] {
|
||||
&self.plans
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn models(&self) -> &[GrokCatalogModel] {
|
||||
&self.models
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GrokCredentialCatalogError {
|
||||
#[error("Grok model catalog has no eligible OAuth account")]
|
||||
@@ -1011,26 +993,12 @@ impl GrokCredentialCatalogService {
|
||||
.map_err(|_| GrokCredentialCatalogError::Cache)
|
||||
}
|
||||
|
||||
/// 实时拉取全部 eligible OAuth account;结果只写可重建 TTL cache,不写 PostgreSQL。
|
||||
pub async fn synchronize(
|
||||
&self,
|
||||
config_revision: ConfigRevision,
|
||||
) -> Result<GrokCredentialCatalogSnapshot, GrokCredentialCatalogError> {
|
||||
let catalog = self.fetch_and_cache().await?;
|
||||
Ok(GrokCredentialCatalogSnapshot {
|
||||
config_revision,
|
||||
observed_at: catalog.observed_at,
|
||||
plans: catalog.plans,
|
||||
models: catalog.models,
|
||||
})
|
||||
}
|
||||
|
||||
/// Provider Registry 构建 RuntimeSnapshot 时使用的实时能力目录。
|
||||
pub async fn query_models(&self) -> Result<Vec<GrokCatalogModel>, GrokCredentialCatalogError> {
|
||||
Ok(self.fetch_and_cache().await?.models)
|
||||
self.fetch_and_cache().await
|
||||
}
|
||||
|
||||
async fn fetch_and_cache(&self) -> Result<FetchedProviderCatalog, GrokCredentialCatalogError> {
|
||||
async fn fetch_and_cache(&self) -> Result<Vec<GrokCatalogModel>, GrokCredentialCatalogError> {
|
||||
let candidates = self
|
||||
.repository
|
||||
.list_loaded_for_provider()
|
||||
@@ -1040,34 +1008,50 @@ impl GrokCredentialCatalogService {
|
||||
.filter(eligible_catalog_candidate)
|
||||
.collect::<Vec<_>>();
|
||||
let groups = catalog_candidates_by_scope(candidates)?;
|
||||
// 单个套餐失败只跳过该套餐,不阻断其他套餐的目录同步。
|
||||
let mut fetched = Vec::with_capacity(groups.len());
|
||||
let mut last_error = None;
|
||||
for (scope, candidates) in groups {
|
||||
fetched.push(self.fetch_scope_catalog(scope, candidates).await?);
|
||||
match self.fetch_scope_catalog(scope.clone(), candidates).await {
|
||||
Ok(catalog) => fetched.push(catalog),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
scope = scope.as_str(),
|
||||
error = %error,
|
||||
"skipping failed xAI plan catalog scope"
|
||||
);
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if fetched.is_empty() {
|
||||
return Err(last_error.unwrap_or(GrokCredentialCatalogError::NoEligibleCredential));
|
||||
}
|
||||
fetched.sort_by(|left, right| left.scope.cmp(&right.scope));
|
||||
let models = strict_model_union(&fetched)?;
|
||||
let observed_at = Utc::now();
|
||||
let plans = fetched
|
||||
.into_iter()
|
||||
.map(|fetched| GrokPlanCatalog {
|
||||
scope: fetched.scope,
|
||||
observed_at,
|
||||
seed: fetched.seed,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for plan in &plans {
|
||||
self.cache
|
||||
.replace(plan.clone())
|
||||
for fetched in fetched {
|
||||
let scope = fetched.scope.clone();
|
||||
if self
|
||||
.cache
|
||||
.replace(GrokPlanCatalog {
|
||||
scope: fetched.scope,
|
||||
observed_at,
|
||||
seed: fetched.seed,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| GrokCredentialCatalogError::Cache)?;
|
||||
.is_err()
|
||||
{
|
||||
// cache 是可重建 TTL 数据,单套餐写入失败不阻断目录发布。
|
||||
tracing::warn!(
|
||||
scope = scope.as_str(),
|
||||
"failed to cache xAI plan catalog scope"
|
||||
);
|
||||
}
|
||||
}
|
||||
self.publish_models(&models);
|
||||
|
||||
Ok(FetchedProviderCatalog {
|
||||
observed_at,
|
||||
plans,
|
||||
models,
|
||||
})
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn refresh_scope_catalog(
|
||||
@@ -1136,12 +1120,6 @@ struct FetchedCredentialCatalog {
|
||||
seed: GrokCredentialCatalogSeed,
|
||||
}
|
||||
|
||||
struct FetchedProviderCatalog {
|
||||
observed_at: DateTime<Utc>,
|
||||
plans: Vec<GrokPlanCatalog>,
|
||||
models: Vec<GrokCatalogModel>,
|
||||
}
|
||||
|
||||
fn catalog_candidates_by_scope(
|
||||
candidates: Vec<LoadedGrokCredential>,
|
||||
) -> Result<BTreeMap<GrokCatalogScope, Vec<LoadedGrokCredential>>, GrokCredentialCatalogError> {
|
||||
|
||||
@@ -29,8 +29,8 @@ pub use catalog::{
|
||||
GROK_FREE_ROLLING_WINDOW_SECONDS, GrokBillingPresentation, GrokCatalogCache,
|
||||
GrokCatalogCacheError, GrokCatalogScope, GrokCredentialCatalogCache,
|
||||
GrokCredentialCatalogError, GrokCredentialCatalogSeed, GrokCredentialCatalogService,
|
||||
GrokCredentialCatalogSnapshot, GrokCredentialQuotaService, GrokPlanCatalog, GrokQuotaError,
|
||||
GrokQuotaPeriodKind, GrokQuotaSnapshot,
|
||||
GrokCredentialQuotaService, GrokPlanCatalog, GrokQuotaError, GrokQuotaPeriodKind,
|
||||
GrokQuotaSnapshot,
|
||||
};
|
||||
pub use discovery::DiscoveryDocument;
|
||||
pub use error::{
|
||||
|
||||
@@ -348,14 +348,13 @@ impl CachedJwk {
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 7517 要求忽略 JWKS 与 JWK 的未知成员;字段本身的强校验在 parse_jwks。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct JwksWire {
|
||||
keys: Vec<JwkWire>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct JwkWire {
|
||||
kty: String,
|
||||
#[serde(rename = "use")]
|
||||
|
||||
@@ -72,17 +72,30 @@ impl GrokAccountSessionSelector {
|
||||
.await
|
||||
.map_err(|_| GrokSessionSelectorError::Unavailable)?;
|
||||
self.quota.prepare_scheduling(&accounts).await;
|
||||
// 目录支持度按套餐 scope 去重后并发批量读取,避免逐账号串行往返。
|
||||
let account_scopes = accounts
|
||||
.iter()
|
||||
.map(|account| GrokCatalogScope::for_account(account).ok())
|
||||
.collect::<Vec<_>>();
|
||||
let unique_scopes = account_scopes
|
||||
.iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let support_reads = futures::future::join_all(unique_scopes.iter().map(|scope| {
|
||||
self.catalog_cache
|
||||
.observed_model_support(scope, request.upstream_model().as_str())
|
||||
}))
|
||||
.await;
|
||||
let support_by_scope = unique_scopes
|
||||
.into_iter()
|
||||
.zip(support_reads)
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let mut catalog_eligible = Vec::new();
|
||||
for account in accounts {
|
||||
let support = match GrokCatalogScope::for_account(&account) {
|
||||
Ok(scope) => {
|
||||
self.catalog_cache
|
||||
.observed_model_support(&scope, request.upstream_model().as_str())
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
if !matches!(support, Ok(Some(false))) {
|
||||
for (account, scope) in accounts.into_iter().zip(account_scopes) {
|
||||
let unsupported = scope
|
||||
.is_some_and(|scope| matches!(support_by_scope.get(&scope), Some(Ok(Some(false)))));
|
||||
if !unsupported {
|
||||
catalog_eligible.push(account);
|
||||
}
|
||||
}
|
||||
@@ -220,9 +233,16 @@ impl GrokAccountSessionSelector {
|
||||
accounts: &[ProviderAccount],
|
||||
) -> std::collections::BTreeMap<ProviderAccountId, SystemTime> {
|
||||
let now = SystemTime::now();
|
||||
// 冷却状态并发批量读取,避免逐账号串行往返。
|
||||
let reads = futures::future::join_all(
|
||||
accounts
|
||||
.iter()
|
||||
.map(|account| self.cooldowns.read(account.id())),
|
||||
)
|
||||
.await;
|
||||
let mut cooled = std::collections::BTreeMap::new();
|
||||
for account in accounts {
|
||||
let Ok(Some(cooldown)) = self.cooldowns.read(account.id()).await else {
|
||||
for (account, read) in accounts.iter().zip(reads) {
|
||||
let Ok(Some(cooldown)) = read else {
|
||||
continue;
|
||||
};
|
||||
if cooldown.credential_revision() != account.revision() {
|
||||
|
||||
@@ -18,7 +18,7 @@ use gateway_core::routing::ProviderKind;
|
||||
use gateway_core::task::WorkerContribution;
|
||||
|
||||
use crate::admin::{XaiAdminProvider, XaiAdminServices};
|
||||
use crate::transport::profile::{GrokCliReleaseService, OfficialGrokCliReleaseTransport};
|
||||
use crate::transport::profile::OfficialGrokCliReleaseTransport;
|
||||
|
||||
pub use config::{XaiConfig, XaiConfigError, XaiWireProfileConfig};
|
||||
pub use transport::XaiWireProfileState;
|
||||
@@ -30,17 +30,17 @@ pub use credential::{
|
||||
GrokAccountExport, GrokAccountProfile, GrokAccountSessionSelector, GrokBillingPresentation,
|
||||
GrokCatalogCache, GrokCatalogCacheError, GrokCatalogScope, GrokCredentialAdmin,
|
||||
GrokCredentialAvailability, GrokCredentialCatalogCache, GrokCredentialCatalogError,
|
||||
GrokCredentialCatalogSeed, GrokCredentialCatalogService, GrokCredentialCatalogSnapshot,
|
||||
GrokCredentialLifecycle, GrokCredentialQuotaService, GrokCredentialRecord,
|
||||
GrokCredentialRecovery, GrokCredentialRecoveryOutcome, GrokCredentialRefreshError,
|
||||
GrokCredentialRefreshOutcome, GrokCredentialRefreshService, GrokCredentialRefresher,
|
||||
GrokCredentialRepository, GrokCredentialRepositoryError, GrokOAuthClient, GrokOAuthConfig,
|
||||
GrokOAuthImportCandidate, GrokOAuthImportDocument, GrokOAuthImportEntry, GrokOAuthImportError,
|
||||
GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthRefreshClient, GrokOAuthSecret,
|
||||
GrokPlanCatalog, GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, GrokRefreshFailure,
|
||||
GrokRefreshTokens, HttpHeader, HttpMethod, OAuthError, OAuthErrorCode, OAuthHttpRequest,
|
||||
OAuthHttpResponse, OAuthHttpTransport, OAuthOperation, OAuthPrincipal, OFFICIAL_CLIENT_ID,
|
||||
OFFICIAL_ISSUER, OFFICIAL_REDIRECT_URI, OFFICIAL_SCOPES, PendingAuthorization, Pkce,
|
||||
GrokCredentialCatalogSeed, GrokCredentialCatalogService, GrokCredentialLifecycle,
|
||||
GrokCredentialQuotaService, GrokCredentialRecord, GrokCredentialRecovery,
|
||||
GrokCredentialRecoveryOutcome, GrokCredentialRefreshError, GrokCredentialRefreshOutcome,
|
||||
GrokCredentialRefreshService, GrokCredentialRefresher, GrokCredentialRepository,
|
||||
GrokCredentialRepositoryError, GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate,
|
||||
GrokOAuthImportDocument, GrokOAuthImportEntry, GrokOAuthImportError, GrokOAuthImportMetadata,
|
||||
GrokOAuthImportTokens, GrokOAuthRefreshClient, GrokOAuthSecret, GrokPlanCatalog,
|
||||
GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, GrokRefreshFailure, GrokRefreshTokens,
|
||||
HttpHeader, HttpMethod, OAuthError, OAuthErrorCode, OAuthHttpRequest, OAuthHttpResponse,
|
||||
OAuthHttpTransport, OAuthOperation, OAuthPrincipal, OFFICIAL_CLIENT_ID, OFFICIAL_ISSUER,
|
||||
OFFICIAL_REDIRECT_URI, OFFICIAL_SCOPES, PendingAuthorization, Pkce,
|
||||
PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, ProtocolViolation,
|
||||
RedirectUriAllowlist, RefreshTokenGrant, RefreshedTokenSet, ReqwestOidcTokenVerifier,
|
||||
RotateGrokCredential, RotateManagedGrokCredential, SecretValue, TokenCandidate,
|
||||
@@ -50,7 +50,10 @@ pub use credential::{
|
||||
VerifiedTokenSet, parse_oauth_error, parse_refresh_success,
|
||||
};
|
||||
pub use provider::GrokBuildProvider;
|
||||
pub use transport::profile::{GROK_CLI_RELEASE_URL, GrokCliReleaseError};
|
||||
pub use transport::profile::{
|
||||
GROK_CLI_RELEASE_URL, GrokCliReleaseError, GrokCliReleaseService, GrokCliReleaseSnapshot,
|
||||
GrokCliReleaseStatus, GrokCliReleaseTransport,
|
||||
};
|
||||
pub use transport::{
|
||||
GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient,
|
||||
GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport,
|
||||
|
||||
@@ -399,6 +399,7 @@ fn compile_grok_model_capabilities(model: GrokCatalogModel) -> ProviderModelCapa
|
||||
.max_output_tokens()
|
||||
.map(std::num::NonZeroU64::get),
|
||||
)
|
||||
.with_upstream_feature_validation()
|
||||
.with_feature(
|
||||
Feature::Reasoning,
|
||||
support(model.capabilities().reasoning_effort()),
|
||||
@@ -441,10 +442,7 @@ fn default_grok_model_presentation() -> ModelPresentation {
|
||||
|
||||
fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
let slug = model.request_model().as_str();
|
||||
let known_grok_4_5 = matches!(
|
||||
slug,
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
);
|
||||
let known_grok_4_5 = is_known_grok_4_5_model(slug);
|
||||
let reasoning_evidence = model.capabilities().reasoning_effort();
|
||||
let catalog_reasoning_efforts = model
|
||||
.capabilities()
|
||||
@@ -513,6 +511,13 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
.with_hidden(model.metadata().hidden().unwrap_or(false))
|
||||
}
|
||||
|
||||
fn is_known_grok_4_5_model(slug: &str) -> bool {
|
||||
matches!(
|
||||
slug,
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct XaiSessionState {
|
||||
account_id: String,
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct XaiWireProfileState(Arc<RwLock<XaiWireProfileConfig>>);
|
||||
|
||||
impl XaiWireProfileState {
|
||||
#[must_use]
|
||||
pub(crate) fn new(profile: XaiWireProfileConfig) -> Self {
|
||||
pub fn new(profile: XaiWireProfileConfig) -> Self {
|
||||
Self(Arc::new(RwLock::new(profile)))
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ impl GrokCliReleaseService {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn status(&self) -> GrokCliReleaseStatus {
|
||||
pub fn status(&self) -> GrokCliReleaseStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
@@ -182,21 +182,21 @@ impl GrokCliReleaseService {
|
||||
|
||||
/// 最近一次官方 Grok CLI 发布检查结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GrokCliReleaseSnapshot {
|
||||
pub(crate) checked_at: Option<DateTime<Utc>>,
|
||||
pub(crate) latest_version: Option<String>,
|
||||
pub(crate) last_error: Option<String>,
|
||||
pub struct GrokCliReleaseSnapshot {
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
pub latest_version: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 内共享的 Grok CLI 发布检查观察状态。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct GrokCliReleaseStatus {
|
||||
pub struct GrokCliReleaseStatus {
|
||||
snapshot: Arc<RwLock<GrokCliReleaseSnapshot>>,
|
||||
}
|
||||
|
||||
impl GrokCliReleaseStatus {
|
||||
#[must_use]
|
||||
pub(crate) fn snapshot(&self) -> GrokCliReleaseSnapshot {
|
||||
pub fn snapshot(&self) -> GrokCliReleaseSnapshot {
|
||||
self.snapshot
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
@@ -255,91 +255,3 @@ pub enum GrokCliReleaseError {
|
||||
#[error("Grok CLI release version is invalid")]
|
||||
InvalidVersion,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ReleaseTransport {
|
||||
outcomes: Mutex<VecDeque<Result<String, GrokCliReleaseError>>>,
|
||||
}
|
||||
|
||||
impl ReleaseTransport {
|
||||
fn new(outcomes: impl IntoIterator<Item = Result<String, GrokCliReleaseError>>) -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GrokCliReleaseTransport for ReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, GrokCliReleaseError>> {
|
||||
let outcome = self
|
||||
.outcomes
|
||||
.lock()
|
||||
.expect("release outcomes lock")
|
||||
.pop_front()
|
||||
.expect("release outcome");
|
||||
Box::pin(async move { outcome })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_refresh_should_update_profile_and_observation() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([Ok("0.2.112".to_owned())])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
assert_eq!(service.refresh().await.expect("release"), "0.2.112");
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert!(snapshot.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_release_refresh_should_keep_last_successful_version() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([
|
||||
Ok("0.2.112".to_owned()),
|
||||
Err(GrokCliReleaseError::InvalidDocument),
|
||||
])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
service.refresh().await.expect("first release");
|
||||
assert!(matches!(
|
||||
service.refresh().await,
|
||||
Err(GrokCliReleaseError::InvalidDocument)
|
||||
));
|
||||
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert_eq!(
|
||||
snapshot.last_error.as_deref(),
|
||||
Some("Grok CLI release document is invalid")
|
||||
);
|
||||
}
|
||||
|
||||
fn wire_profile() -> XaiWireProfileState {
|
||||
XaiWireProfileState::new(XaiWireProfileConfig {
|
||||
client_identifier: "grok-shell".to_owned(),
|
||||
client_version: "0.2.106".to_owned(),
|
||||
client_mode: "headless".to_owned(),
|
||||
target_os: "linux".to_owned(),
|
||||
target_arch: "x86_64".to_owned(),
|
||||
verified_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ const SESSION_FIELDS: &[&str] = &[
|
||||
const FOREIGN_CLIENT_METADATA_FIELDS: &[&str] = &["x-openai-subagent"];
|
||||
const MAX_SESSION_SEED_BYTES: usize = 1_024;
|
||||
const GROK_CACHE_ROUTE_TOOLS: &[&str] = &["web_search", "x_search"];
|
||||
/// Grok CLI 在历史条目上注入、Grok Build 无法反序列化的内部键;只在已知
|
||||
/// 注入位置剥离,避免误伤工具 schema 或输出中恰好同名的键。
|
||||
const GROK_INTERNAL_HISTORY_KEYS: &[&str] =
|
||||
&["phase", "internal_chat_message_metadata_passthrough"];
|
||||
|
||||
/// 保留客户端 OpenAI Responses object 的 xAI 上游请求。
|
||||
pub struct GrokResponsesRequest {
|
||||
@@ -324,17 +328,16 @@ fn enable_grok_prompt_cache_route(body: &mut Map<String, Value>) {
|
||||
let Value::Array(tools) = tools else {
|
||||
return;
|
||||
};
|
||||
let had_client_tools = !tools.is_empty();
|
||||
// 缓存路由不得改变模型可调用的工具集:客户端带工具时无法既保留其
|
||||
// tool_choice 语义又阻止注入的原生搜索被调用,因此直接不注入。
|
||||
if !tools.is_empty() {
|
||||
return;
|
||||
}
|
||||
for tool in GROK_CACHE_ROUTE_TOOLS {
|
||||
if !has_tool_type(tools, tool) {
|
||||
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
|
||||
}
|
||||
}
|
||||
// 无客户端工具时禁用工具调用,使原生搜索声明只承担缓存路由作用;
|
||||
// 混合工具请求保留客户端原有的 tool_choice 语义。
|
||||
if !had_client_tools {
|
||||
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
|
||||
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
|
||||
}
|
||||
// 禁用工具调用,使原生搜索声明只承担缓存路由作用。
|
||||
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
|
||||
}
|
||||
|
||||
fn explicit_session_seed(request: &GenerateRequest, body: &Map<String, Value>) -> Option<String> {
|
||||
@@ -2212,11 +2215,14 @@ impl ToolNormalizer {
|
||||
role => role,
|
||||
};
|
||||
let content = self.normalize_message_content(item.get("content"), role)?;
|
||||
Ok(Map::from_iter([
|
||||
("type".to_owned(), Value::String("message".to_owned())),
|
||||
("role".to_owned(), Value::String(role.to_owned())),
|
||||
("content".to_owned(), content),
|
||||
]))
|
||||
// 透明代理合法形态:只覆盖归一化字段并剥离 Grok 注入键,未知官方
|
||||
// 字段原样保留。
|
||||
let mut converted = item.clone();
|
||||
strip_grok_internal_keys(&mut converted);
|
||||
converted.insert("type".to_owned(), Value::String("message".to_owned()));
|
||||
converted.insert("role".to_owned(), Value::String(role.to_owned()));
|
||||
converted.insert("content".to_owned(), content);
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
fn normalize_message_content(
|
||||
@@ -2322,12 +2328,14 @@ impl ToolNormalizer {
|
||||
if !namespace.is_empty() {
|
||||
name = self.alias(ToolIdentity::new(ToolKind::Function, namespace, &name));
|
||||
}
|
||||
Ok(Map::from_iter([
|
||||
("type".to_owned(), Value::String("function_call".to_owned())),
|
||||
("call_id".to_owned(), Value::String(call_id.to_owned())),
|
||||
("name".to_owned(), Value::String(name)),
|
||||
("arguments".to_owned(), Value::String(arguments)),
|
||||
]))
|
||||
let mut converted = item.clone();
|
||||
strip_grok_internal_keys(&mut converted);
|
||||
converted.remove("namespace");
|
||||
converted.insert("type".to_owned(), Value::String("function_call".to_owned()));
|
||||
converted.insert("call_id".to_owned(), Value::String(call_id.to_owned()));
|
||||
converted.insert("name".to_owned(), Value::String(name));
|
||||
converted.insert("arguments".to_owned(), Value::String(arguments));
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
fn normalize_custom_tool_call_input(
|
||||
@@ -2348,12 +2356,15 @@ impl ToolNormalizer {
|
||||
)]))
|
||||
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization)?;
|
||||
let alias = self.alias(identity);
|
||||
Ok(Map::from_iter([
|
||||
("type".to_owned(), Value::String("function_call".to_owned())),
|
||||
("call_id".to_owned(), Value::String(call_id.to_owned())),
|
||||
("name".to_owned(), Value::String(alias)),
|
||||
("arguments".to_owned(), Value::String(arguments)),
|
||||
]))
|
||||
let mut converted = item.clone();
|
||||
strip_grok_internal_keys(&mut converted);
|
||||
converted.remove("namespace");
|
||||
converted.remove("input");
|
||||
converted.insert("type".to_owned(), Value::String("function_call".to_owned()));
|
||||
converted.insert("call_id".to_owned(), Value::String(call_id.to_owned()));
|
||||
converted.insert("name".to_owned(), Value::String(alias));
|
||||
converted.insert("arguments".to_owned(), Value::String(arguments));
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
fn normalize_function_call_output_input(
|
||||
@@ -2370,14 +2381,15 @@ impl ToolNormalizer {
|
||||
}
|
||||
output => Value::String(encode_tool_output(output)?),
|
||||
};
|
||||
Ok(Map::from_iter([
|
||||
(
|
||||
"type".to_owned(),
|
||||
Value::String("function_call_output".to_owned()),
|
||||
),
|
||||
("call_id".to_owned(), Value::String(call_id.to_owned())),
|
||||
("output".to_owned(), output),
|
||||
]))
|
||||
let mut converted = item.clone();
|
||||
strip_grok_internal_keys(&mut converted);
|
||||
converted.insert(
|
||||
"type".to_owned(),
|
||||
Value::String("function_call_output".to_owned()),
|
||||
);
|
||||
converted.insert("call_id".to_owned(), Value::String(call_id.to_owned()));
|
||||
converted.insert("output".to_owned(), output);
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
fn normalize_function_output_blocks(
|
||||
@@ -2606,6 +2618,8 @@ fn require_content_source(
|
||||
fn sanitize_reasoning_input(item: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut converted =
|
||||
copy_non_null_history_fields(item, &["id", "summary", "content", "encrypted_content"]);
|
||||
// Grok CLI 会在 summary/content 条目上注入 phase 等内部键。
|
||||
strip_grok_internal_entry_keys(&mut converted, &["summary", "content"]);
|
||||
converted.insert("type".to_owned(), Value::String("reasoning".to_owned()));
|
||||
if converted
|
||||
.get("encrypted_content")
|
||||
@@ -2679,6 +2693,14 @@ fn sanitize_native_history_input(item: &Map<String, Value>, item_type: &str) ->
|
||||
_ => &[],
|
||||
};
|
||||
let mut converted = copy_non_null_history_fields(item, fields);
|
||||
// Grok CLI 会在 shell_call 的 action object 里注入内部键与 null 占位
|
||||
// 字段(如 `timeout_ms: null`);只在这一层剥离,深层内容原样保留。
|
||||
if item_type == "shell_call"
|
||||
&& let Some(Value::Object(action)) = converted.get_mut("action")
|
||||
{
|
||||
strip_grok_internal_keys(action);
|
||||
action.retain(|_, value| !value.is_null());
|
||||
}
|
||||
converted.insert("type".to_owned(), Value::String(item_type.to_owned()));
|
||||
converted
|
||||
}
|
||||
@@ -2688,36 +2710,28 @@ fn copy_non_null_history_fields(item: &Map<String, Value>, fields: &[&str]) -> M
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
item.get(*field)
|
||||
.and_then(sanitize_history_json_value)
|
||||
.map(|value| ((*field).to_owned(), value))
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| ((*field).to_owned(), value.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sanitize_history_json_value(value: &Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::Object(object) => Some(Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"phase" | "internal_chat_message_metadata_passthrough"
|
||||
)
|
||||
})
|
||||
.filter_map(|(key, value)| {
|
||||
sanitize_history_json_value(value).map(|value| (key.clone(), value))
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
Value::Array(values) => Some(Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(sanitize_history_json_value)
|
||||
.collect(),
|
||||
)),
|
||||
value => Some(value.clone()),
|
||||
fn strip_grok_internal_keys(object: &mut Map<String, Value>) {
|
||||
for key in GROK_INTERNAL_HISTORY_KEYS {
|
||||
object.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_grok_internal_entry_keys(converted: &mut Map<String, Value>, fields: &[&str]) {
|
||||
for field in fields {
|
||||
let Some(Value::Array(entries)) = converted.get_mut(*field) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries {
|
||||
if let Some(object) = entry.as_object_mut() {
|
||||
strip_grok_internal_keys(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,12 +335,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
|
||||
.get("mutation")
|
||||
.and_then(Value::as_object)
|
||||
.expect("pending mutation");
|
||||
assert_eq!(
|
||||
mutation
|
||||
.get("expected_config_revision")
|
||||
.and_then(Value::as_u64),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(mutation.get("expected_config_revision"), None);
|
||||
assert_eq!(
|
||||
mutation.get("started_request_id").and_then(Value::as_str),
|
||||
Some("request-start")
|
||||
@@ -555,7 +550,6 @@ fn pending_payload(flow_id: &str, owner_ref: &str) -> OpaqueProviderData {
|
||||
"expires_at": (Utc::now() + chrono::Duration::minutes(30)).to_rfc3339(),
|
||||
"server_state": server_state,
|
||||
"mutation": {
|
||||
"expected_config_revision": 7,
|
||||
"provider_kind": "xai",
|
||||
"target": {
|
||||
"kind": "create",
|
||||
|
||||
@@ -3,17 +3,19 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use futures::future::join_all;
|
||||
use futures::future::{BoxFuture, join_all};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, AccountStateChange, CredentialCasUpdate, CredentialRevision,
|
||||
OpaqueProviderData, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaObservation,
|
||||
};
|
||||
use gateway_core::routing::ConfigRevision;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderStoreError,
|
||||
};
|
||||
use provider_xai::{
|
||||
GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
|
||||
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
|
||||
GrokCatalogScope, GrokCredentialCatalogCache, GrokCredentialCatalogError,
|
||||
GrokCatalogCache, GrokCatalogScope, GrokCredentialCatalogCache, GrokCredentialCatalogError,
|
||||
GrokCredentialCatalogSeed, GrokCredentialRepository, GrokModelCatalogRequest,
|
||||
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
|
||||
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokPlanCatalog,
|
||||
@@ -122,6 +124,31 @@ impl GrokBillingTransport for QueueBillingTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次读取都返回同一份无法解码的 catalog 文档的存储端口。
|
||||
struct CorruptCatalogCachePort;
|
||||
|
||||
impl ProviderCatalogCachePort for CorruptCatalogCachePort {
|
||||
fn replace<'a>(
|
||||
&'a self,
|
||||
_key: &'a ProviderCatalogCacheKey,
|
||||
_catalog: &'a OpaqueProviderData,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<(), ProviderStoreError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
_key: &'a ProviderCatalogCacheKey,
|
||||
) -> BoxFuture<'a, Result<Option<OpaqueProviderData>, ProviderStoreError>> {
|
||||
Box::pin(async {
|
||||
let mut document = serde_json::Map::new();
|
||||
document.insert("version".to_owned(), serde_json::json!("corrupt"));
|
||||
Ok(Some(OpaqueProviderData::new(document)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum BillingMutation {
|
||||
State(AccountStateChange),
|
||||
Credential(CredentialCasUpdate),
|
||||
@@ -204,7 +231,7 @@ async fn concurrent_cold_scheduling_hydration_reads_quota_once() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synchronization_caches_each_plan_and_returns_strict_union() {
|
||||
async fn catalog_query_caches_each_plan_and_returns_strict_union() {
|
||||
let (store, repository) =
|
||||
repository_with_accounts(&[("catalog-a", "subject-a"), ("catalog-b", "subject-b")]).await;
|
||||
let cache = MemoryGrokCatalogCache::shared();
|
||||
@@ -217,17 +244,13 @@ async fn synchronization_caches_each_plan_and_returns_strict_union() {
|
||||
]);
|
||||
let service = crate::support::grok_catalog_service(repository, transport, cache_port);
|
||||
assert_eq!(service.catalog_generation().get(), 0);
|
||||
let snapshot = service
|
||||
.synchronize(ConfigRevision::new(7).expect("config revision"))
|
||||
.await
|
||||
.expect("catalog sync");
|
||||
let models = service.query_models().await.expect("catalog sync");
|
||||
assert_eq!(service.catalog_generation().get(), 1);
|
||||
service.query_models().await.expect("same catalog sync");
|
||||
assert_eq!(service.catalog_generation().get(), 1);
|
||||
|
||||
assert_eq!(snapshot.plans().len(), 1);
|
||||
assert_eq!(snapshot.models().len(), 1);
|
||||
assert_eq!(snapshot.models()[0].request_model().as_str(), "grok-4.5");
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].request_model().as_str(), "grok-4.5");
|
||||
let account = store
|
||||
.account(&account_id("catalog-a"))
|
||||
.expect("created account");
|
||||
@@ -292,6 +315,35 @@ async fn single_account_catalog_read_miss_does_not_call_upstream() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_catalog_cache_entry_reads_as_miss_and_refetches() {
|
||||
let (store, repository) =
|
||||
repository_with_accounts(&[("catalog-corrupt", "subject-corrupt")]).await;
|
||||
let account = store
|
||||
.account(&account_id("catalog-corrupt"))
|
||||
.expect("created account");
|
||||
let scope = GrokCatalogScope::for_account(&account).expect("catalog scope");
|
||||
let cache = GrokCatalogCache::new(Arc::new(CorruptCatalogCachePort)).expect("provider cache");
|
||||
assert!(
|
||||
cache
|
||||
.read(&scope)
|
||||
.await
|
||||
.expect("corrupt cache entry must degrade to a miss")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let transport = QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]);
|
||||
let service =
|
||||
crate::support::grok_catalog_service(repository, transport.clone(), Arc::new(cache));
|
||||
let catalog = service
|
||||
.cached_or_refresh_account_catalog(&account)
|
||||
.await
|
||||
.expect("corrupt cache entry falls back to a live refresh");
|
||||
|
||||
assert_eq!(catalog.seed().models(), ["grok-4.5"]);
|
||||
assert_eq!(transport.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_account_catalog_uses_shared_plan_cache_without_upstream_call() {
|
||||
let (store, repository) =
|
||||
@@ -424,9 +476,7 @@ async fn disabled_accounts_are_not_sent_to_catalog_transport() {
|
||||
cache_port,
|
||||
);
|
||||
assert!(matches!(
|
||||
service
|
||||
.synchronize(ConfigRevision::new(1).expect("revision"))
|
||||
.await,
|
||||
service.query_models().await,
|
||||
Err(GrokCredentialCatalogError::NoEligibleCredential)
|
||||
));
|
||||
}
|
||||
@@ -453,16 +503,16 @@ async fn quota_exhausted_account_remains_eligible_for_catalog_discovery() {
|
||||
MemoryGrokCatalogCache::shared(),
|
||||
);
|
||||
|
||||
let snapshot = service
|
||||
.synchronize(ConfigRevision::new(1).expect("revision"))
|
||||
let models = service
|
||||
.query_models()
|
||||
.await
|
||||
.expect("discover catalog through quota exhausted account");
|
||||
|
||||
assert_eq!(snapshot.models().len(), 1);
|
||||
assert_eq!(models.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_upstream_failure_rejects_the_whole_catalog_cycle() {
|
||||
async fn sole_plan_failure_rejects_the_catalog_cycle() {
|
||||
let (_, repository) = repository_with_accounts(&[("failed", "subject-failed")]).await;
|
||||
let cache_port: Arc<dyn GrokCredentialCatalogCache> = MemoryGrokCatalogCache::shared();
|
||||
let service = crate::support::grok_catalog_service(
|
||||
@@ -471,13 +521,68 @@ async fn one_upstream_failure_rejects_the_whole_catalog_cycle() {
|
||||
cache_port,
|
||||
);
|
||||
assert!(matches!(
|
||||
service
|
||||
.synchronize(ConfigRevision::new(1).expect("revision"))
|
||||
.await,
|
||||
service.query_models().await,
|
||||
Err(GrokCredentialCatalogError::Upstream)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_plan_scope_is_skipped_and_surviving_plans_still_cache() {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let account_store: Arc<dyn ProviderAccountStore> = store.clone();
|
||||
let repository = GrokCredentialRepository::new(account_store);
|
||||
// "plan:pro" 按 scope 排序在 "plan:standard" 之前,先消费失败响应。
|
||||
let mut pro = create_input("plan-pro", "subject-pro");
|
||||
pro.account.plan_type = Some("pro".to_owned());
|
||||
seed_input(&store, &pro).await.expect("create pro account");
|
||||
seed_input(&store, &create_input("plan-standard", "subject-standard"))
|
||||
.await
|
||||
.expect("create standard account");
|
||||
let transport = QueueCatalogTransport::from_results([
|
||||
Err(GrokModelCatalogTransportError::new(
|
||||
GrokModelCatalogTransportErrorKind::Unavailable,
|
||||
)),
|
||||
Ok(GrokModelCatalogTransportResponse::new(
|
||||
OFFICIAL_FIXTURE.to_vec(),
|
||||
None,
|
||||
)),
|
||||
]);
|
||||
let cache = MemoryGrokCatalogCache::shared();
|
||||
let cache_port: Arc<dyn GrokCredentialCatalogCache> = cache.clone();
|
||||
let service = crate::support::grok_catalog_service(repository, transport.clone(), cache_port);
|
||||
|
||||
let models = service
|
||||
.query_models()
|
||||
.await
|
||||
.expect("surviving plan still refreshes the catalog");
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].request_model().as_str(), "grok-4.5");
|
||||
assert_eq!(transport.calls(), 2);
|
||||
let standard = store
|
||||
.account(&account_id("plan-standard"))
|
||||
.expect("created account");
|
||||
let standard_scope = GrokCatalogScope::for_account(&standard).expect("catalog scope");
|
||||
assert_eq!(
|
||||
cache
|
||||
.observed_model_support(&standard_scope, "grok-4.5")
|
||||
.await
|
||||
.expect("cache lookup"),
|
||||
Some(true)
|
||||
);
|
||||
let pro = store
|
||||
.account(&account_id("plan-pro"))
|
||||
.expect("created account");
|
||||
let pro_scope = GrokCatalogScope::for_account(&pro).expect("catalog scope");
|
||||
assert_eq!(
|
||||
cache
|
||||
.observed_model_support(&pro_scope, "grok-4.5")
|
||||
.await
|
||||
.expect("cache lookup"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_facts_for_same_slug_fail_closed() {
|
||||
let (store, repository) =
|
||||
@@ -505,9 +610,7 @@ async fn conflicting_facts_for_same_slug_fail_closed() {
|
||||
MemoryGrokCatalogCache::shared(),
|
||||
);
|
||||
assert!(matches!(
|
||||
service
|
||||
.synchronize(ConfigRevision::new(1).expect("revision"))
|
||||
.await,
|
||||
service.query_models().await,
|
||||
Err(GrokCredentialCatalogError::ConflictingModelFacts)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,34 @@ async fn authorization_code_should_validate_es256_and_cache_jwks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jwks_with_unknown_members_should_still_verify() {
|
||||
let server = MockServer::start().await;
|
||||
let endpoint = endpoints(&server);
|
||||
let key = TestKey::new(23, "extended-key");
|
||||
let mut jwks = key.jwks();
|
||||
jwks["cache_max_age"] = json!(3600);
|
||||
jwks["keys"][0]["x5t"] = json!("thumbprint");
|
||||
jwks["keys"][0]["key_ops"] = json!(["verify"]);
|
||||
mount_jwks(&server, jwks, 1).await;
|
||||
let verifier = verifier(&endpoint.origin, CACHE_TTL);
|
||||
let nonce = SecretValue::new("unknown-member-nonce".to_owned());
|
||||
let algorithms = vec!["ES256".to_owned()];
|
||||
let token = SecretValue::new(key.sign(&valid_claims(&nonce), Algorithm::ES256));
|
||||
let access_token = SecretValue::new("access-token".to_owned());
|
||||
|
||||
let evidence = verifier
|
||||
.verify(
|
||||
auth_context(&endpoint, &algorithms, &nonce),
|
||||
TokenCandidate::new(&access_token, Some(&token), None),
|
||||
)
|
||||
.await
|
||||
.expect("JWKS with unknown members must still verify");
|
||||
|
||||
assert_eq!(evidence.method(), VerificationMethod::IdToken);
|
||||
assert_eq!(evidence.subject(), "user-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_cache_miss_should_refresh_once_for_key_rotation() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -60,11 +60,11 @@ fn observed_transport_metrics() -> GrokInferenceTransportMetrics {
|
||||
))
|
||||
}
|
||||
|
||||
const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
|
||||
const CATALOG_WITHOUT_FEATURE_METADATA: &[u8] = br#"{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": "grok-4.5-catalog-entry",
|
||||
"model": "grok-4.5",
|
||||
"id": "grok-catalog-entry",
|
||||
"model": "grok-catalog-entry",
|
||||
"contextWindow": 1000000,
|
||||
"maxCompletionTokens": 131072,
|
||||
"apiBackend": "responses",
|
||||
@@ -368,13 +368,13 @@ impl GrokModelCatalogTransport for StaticCatalogTransport {
|
||||
}
|
||||
}
|
||||
|
||||
struct CatalogWithoutToolMetadataTransport;
|
||||
struct CatalogWithoutFeatureMetadataTransport;
|
||||
|
||||
impl GrokModelCatalogTransport for CatalogWithoutToolMetadataTransport {
|
||||
impl GrokModelCatalogTransport for CatalogWithoutFeatureMetadataTransport {
|
||||
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
|
||||
Box::pin(async {
|
||||
Ok(GrokModelCatalogTransportResponse::new(
|
||||
CATALOG_WITHOUT_TOOL_METADATA,
|
||||
CATALOG_WITHOUT_FEATURE_METADATA,
|
||||
None,
|
||||
))
|
||||
})
|
||||
@@ -1918,7 +1918,7 @@ async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
|
||||
StubSelector::success(),
|
||||
StubInferenceTransport::success(),
|
||||
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
|
||||
Arc::new(CatalogWithoutToolMetadataTransport),
|
||||
Arc::new(CatalogWithoutFeatureMetadataTransport),
|
||||
)
|
||||
.await;
|
||||
let capabilities = provider
|
||||
@@ -1935,3 +1935,29 @@ async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_catalog_feature_metadata_keeps_build_responses_routable() {
|
||||
let provider = provider_with_catalog_transport(
|
||||
StubSelector::success(),
|
||||
StubInferenceTransport::success(),
|
||||
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
|
||||
Arc::new(CatalogWithoutFeatureMetadataTransport),
|
||||
)
|
||||
.await;
|
||||
let capabilities = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("capabilities");
|
||||
|
||||
assert!(
|
||||
capabilities[0]
|
||||
.capabilities()
|
||||
.match_requirements(
|
||||
&gateway_core::operation::CapabilityRequirements::new(OperationKind::Generate)
|
||||
.require(Feature::Vision)
|
||||
.require(Feature::JsonSchema)
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ mod compaction;
|
||||
mod config;
|
||||
mod headers;
|
||||
mod network;
|
||||
mod profile;
|
||||
mod request;
|
||||
mod session;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::future::BoxFuture;
|
||||
use provider_xai::{
|
||||
GrokCliReleaseError, GrokCliReleaseService, GrokCliReleaseTransport, XaiWireProfileConfig,
|
||||
XaiWireProfileState,
|
||||
};
|
||||
|
||||
struct ReleaseTransport {
|
||||
outcomes: Mutex<VecDeque<Result<String, GrokCliReleaseError>>>,
|
||||
}
|
||||
|
||||
impl ReleaseTransport {
|
||||
fn new(outcomes: impl IntoIterator<Item = Result<String, GrokCliReleaseError>>) -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GrokCliReleaseTransport for ReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, GrokCliReleaseError>> {
|
||||
let outcome = self
|
||||
.outcomes
|
||||
.lock()
|
||||
.expect("release outcomes lock")
|
||||
.pop_front()
|
||||
.expect("release outcome");
|
||||
Box::pin(async move { outcome })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_refresh_should_update_profile_and_observation() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([Ok("0.2.112".to_owned())])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
assert_eq!(service.refresh().await.expect("release"), "0.2.112");
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert!(snapshot.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_release_refresh_should_keep_last_successful_version() {
|
||||
let profile = wire_profile();
|
||||
let service = GrokCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(ReleaseTransport::new([
|
||||
Ok("0.2.112".to_owned()),
|
||||
Err(GrokCliReleaseError::InvalidDocument),
|
||||
])),
|
||||
);
|
||||
let status = service.status();
|
||||
|
||||
service.refresh().await.expect("first release");
|
||||
assert!(matches!(
|
||||
service.refresh().await,
|
||||
Err(GrokCliReleaseError::InvalidDocument)
|
||||
));
|
||||
assert_eq!(profile.client_version(), "0.2.112");
|
||||
let snapshot = status.snapshot();
|
||||
assert!(snapshot.checked_at.is_some());
|
||||
assert_eq!(snapshot.latest_version.as_deref(), Some("0.2.112"));
|
||||
assert_eq!(
|
||||
snapshot.last_error.as_deref(),
|
||||
Some("Grok CLI release document is invalid")
|
||||
);
|
||||
}
|
||||
|
||||
fn wire_profile() -> XaiWireProfileState {
|
||||
XaiWireProfileState::new(XaiWireProfileConfig {
|
||||
client_identifier: "grok-shell".to_owned(),
|
||||
client_version: "0.2.106".to_owned(),
|
||||
client_mode: "headless".to_owned(),
|
||||
target_os: "linux".to_owned(),
|
||||
target_arch: "x86_64".to_owned(),
|
||||
verified_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -241,7 +241,7 @@ fn explicit_session_should_enable_the_noop_native_cache_route() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_session_should_merge_client_tools_into_the_native_cache_route() {
|
||||
fn explicit_session_should_not_add_invokable_cache_tools_to_client_tool_requests() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"prompt_cache_key": "conversation-42",
|
||||
@@ -258,17 +258,14 @@ fn explicit_session_should_merge_client_tools_into_the_native_cache_route() {
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
// 缓存路由不得扩大模型可调用的工具集:带客户端工具时不注入原生搜索。
|
||||
assert_eq!(
|
||||
body.pointer("/tools"),
|
||||
Some(&json!([
|
||||
{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"parameters": {"type": "object"}
|
||||
},
|
||||
{"type": "web_search"},
|
||||
{"type": "x_search"}
|
||||
]))
|
||||
Some(&json!([{
|
||||
"type": "function",
|
||||
"name": "read_file",
|
||||
"parameters": {"type": "object"}
|
||||
}]))
|
||||
);
|
||||
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
|
||||
}
|
||||
@@ -372,7 +369,7 @@ fn function_parameters_should_report_the_invalid_nullable_root_field() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_session_should_enable_cache_after_codex_additional_tools_normalization() {
|
||||
fn explicit_session_should_not_inject_cache_tools_after_codex_additional_tools_normalization() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"prompt_cache_key": "conversation-42",
|
||||
@@ -405,8 +402,8 @@ fn explicit_session_should_enable_cache_after_codex_additional_tools_normalizati
|
||||
Some(&json!(["patch"]))
|
||||
);
|
||||
assert_eq!(body.pointer("/tools/1/name"), Some(&json!("read_file")));
|
||||
assert_eq!(body.pointer("/tools/2/type"), Some(&json!("web_search")));
|
||||
assert_eq!(body.pointer("/tools/3/type"), Some(&json!("x_search")));
|
||||
// 归一化装载的会话工具视同客户端工具:不再追加可被调用的原生搜索。
|
||||
assert_eq!(body.pointer("/tools/2"), None);
|
||||
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
|
||||
}
|
||||
|
||||
@@ -660,7 +657,7 @@ fn history_should_rebuild_codex_calls_outputs_shell_and_private_fields() {
|
||||
{"type": "local_shell"}
|
||||
],
|
||||
"input": [
|
||||
{"type": "message", "role": "assistant", "id": "drop", "content": [
|
||||
{"type": "message", "role": "assistant", "id": "msg_1", "content": [
|
||||
{"type": "output_text", "text": "done"},
|
||||
{"type": "refusal", "refusal": "no"}
|
||||
]},
|
||||
@@ -680,7 +677,7 @@ fn history_should_rebuild_codex_calls_outputs_shell_and_private_fields() {
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/input/0/content"), Some(&json!("done\nno")));
|
||||
assert_eq!(body.pointer("/input/0/id"), None);
|
||||
assert_eq!(body.pointer("/input/0/id"), Some(&json!("msg_1")));
|
||||
assert_eq!(body.pointer("/input/1/type"), Some(&json!("function_call")));
|
||||
assert_eq!(
|
||||
body.pointer("/input/1/arguments"),
|
||||
@@ -712,6 +709,130 @@ fn history_should_rebuild_codex_calls_outputs_shell_and_private_fields() {
|
||||
assert_eq!(body.pointer("/input/8/role"), Some(&json!("developer")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_rebuild_should_preserve_unknown_fields_and_strip_grok_internal_keys() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": [
|
||||
{
|
||||
"type": "message", "role": "assistant", "status": "completed",
|
||||
"phase": "final_answer",
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "t1"},
|
||||
"future_message_field": {"keep": true},
|
||||
"content": [{"type": "output_text", "text": "hi"}]
|
||||
},
|
||||
{
|
||||
"type": "function_call", "id": "fc_1", "call_id": "call_1",
|
||||
"name": "read_file", "arguments": "{}",
|
||||
"future_call_field": 7,
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "t1"}
|
||||
},
|
||||
{
|
||||
"type": "function_call_output", "id": "fco_1", "call_id": "call_1",
|
||||
"output": "ok", "future_output_field": true,
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "t1"}
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call", "id": "ctc_1", "call_id": "call_2",
|
||||
"name": "render", "input": "raw", "status": "completed",
|
||||
"future_custom_field": "keep",
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "t1"}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("history normalization");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
for pointer in [
|
||||
"/input/0/phase",
|
||||
"/input/0/internal_chat_message_metadata_passthrough",
|
||||
"/input/1/internal_chat_message_metadata_passthrough",
|
||||
"/input/2/internal_chat_message_metadata_passthrough",
|
||||
"/input/3/internal_chat_message_metadata_passthrough",
|
||||
"/input/3/input",
|
||||
] {
|
||||
assert_eq!(body.pointer(pointer), None, "field survived at {pointer}");
|
||||
}
|
||||
assert_eq!(
|
||||
body.pointer("/input/0/future_message_field"),
|
||||
Some(&json!({"keep": true}))
|
||||
);
|
||||
assert_eq!(body.pointer("/input/0/status"), Some(&json!("completed")));
|
||||
assert_eq!(body.pointer("/input/0/content"), Some(&json!("hi")));
|
||||
assert_eq!(body.pointer("/input/1/id"), Some(&json!("fc_1")));
|
||||
assert_eq!(body.pointer("/input/1/future_call_field"), Some(&json!(7)));
|
||||
assert_eq!(
|
||||
body.pointer("/input/2/future_output_field"),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(body.pointer("/input/2/output"), Some(&json!("ok")));
|
||||
assert_eq!(body.pointer("/input/3/type"), Some(&json!("function_call")));
|
||||
assert_eq!(
|
||||
body.pointer("/input/3/future_custom_field"),
|
||||
Some(&json!("keep"))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/input/3/arguments"),
|
||||
Some(&json!("{\"input\":\"raw\"}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_sanitizer_should_only_strip_known_grok_injection_sites() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": [
|
||||
{"type": "mcp_list_tools", "id": "ml_1", "server_label": "srv", "tools": [
|
||||
{
|
||||
"name": "deploy",
|
||||
"input_schema": {
|
||||
"properties": {"phase": {"type": "string"}},
|
||||
"required": ["phase"]
|
||||
}
|
||||
}
|
||||
]},
|
||||
{
|
||||
"type": "mcp_call", "id": "mc_1", "name": "deploy",
|
||||
"server_label": "srv", "arguments": "{}",
|
||||
"output": {"result": null, "phase": "keep"}
|
||||
},
|
||||
{
|
||||
"type": "shell_call", "id": "sh_1", "call_id": "call_9",
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"commands": ["pwd"],
|
||||
"timeout_ms": null,
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "t1"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("history normalization");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
// 工具 schema 里恰好叫 phase 的属性与语义 null 不再被误删。
|
||||
assert_eq!(
|
||||
body.pointer("/input/0/tools/0/input_schema/properties/phase"),
|
||||
Some(&json!({"type": "string"}))
|
||||
);
|
||||
assert_eq!(body.pointer("/input/1/output/result"), Some(&Value::Null));
|
||||
assert_eq!(body.pointer("/input/1/output/phase"), Some(&json!("keep")));
|
||||
// shell_call action 是已知注入点:内部键与 null 占位字段仍被剥离。
|
||||
assert_eq!(
|
||||
body.pointer("/input/2/action/commands/0"),
|
||||
Some(&json!("pwd"))
|
||||
);
|
||||
assert_eq!(body.pointer("/input/2/action/timeout_ms"), None);
|
||||
assert_eq!(
|
||||
body.pointer("/input/2/action/internal_chat_message_metadata_passthrough"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_apply_patch_history_should_wrap_raw_input_in_patch_field() {
|
||||
let patch = concat!(
|
||||
|
||||
@@ -33,24 +33,6 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cargo build --release --locked --bin codex-proxy-rs \
|
||||
&& cp "${CARGO_TARGET_DIR}/release/codex-proxy-rs" /tmp/codex-proxy-rs
|
||||
|
||||
FROM backend-builder AS release-asset-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /out
|
||||
COPY --from=frontend-builder /app/frontend/dist /tmp/release/codex-proxy-rs/web/dist
|
||||
RUN test -n "${CPR_VERSION}" \
|
||||
&& test -n "${TARGETOS}" \
|
||||
&& test -n "${TARGETARCH}" \
|
||||
&& cp /tmp/codex-proxy-rs /tmp/release/codex-proxy-rs/ \
|
||||
&& tar -czf "/out/codex-proxy-rs_${CPR_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz" \
|
||||
-C /tmp/release codex-proxy-rs
|
||||
|
||||
FROM scratch AS release-asset
|
||||
|
||||
COPY --from=release-asset-builder /out /
|
||||
|
||||
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 AS runtime-base
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
+3
-1
@@ -77,7 +77,9 @@ services:
|
||||
restart: unless-stopped
|
||||
user: '10001:10001'
|
||||
init: true
|
||||
stop_grace_period: 30s
|
||||
# 关闭是串行两段:HTTP drain(host.drain_timeout_seconds=30s)之后 worker join
|
||||
# (host.worker_shutdown_timeout_seconds=30s),宽限期必须大于两者之和。
|
||||
stop_grace_period: 75s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
+15
-19
@@ -41,13 +41,13 @@ Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不
|
||||
|
||||
错误响应仍使用 `code`、`message`、`data`,其中 `data` 为 `null`。稳定业务码包括参数错误
|
||||
`40001`、会话缺失 `40101`、登录凭据错误 `40102`、管理 API Key 错误 `40103`、资源不存在
|
||||
`40401`、revision 冲突 `40901`、内部错误 `50001`、上游失败 `50201` 和依赖不可用 `50301`。
|
||||
`40401`、业务冲突 `40901`、内部错误 `50001`、上游失败 `50201` 和依赖不可用 `50301`。
|
||||
|
||||
### 配置 revision
|
||||
### 管理写入一致性
|
||||
|
||||
会改变路由快照或安全配置的 mutation 使用 `expectedConfigRevision` 做乐观并发控制。客户端应从
|
||||
账号、Client Key 或设置查询结果读取最新 `configRevision`;收到 `40901` 后重新读取,不应盲目重试
|
||||
旧 revision。
|
||||
Admin API 不暴露全局配置版本,mutation 请求也不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进内部配置 revision,并用于快照发布与审计。
|
||||
|
||||
单个 credential 的轮换或重新授权仍使用 `expectedCredentialRevision`,它只保护该 credential 本身,不是全局配置锁。
|
||||
|
||||
## 2. 健康检查
|
||||
|
||||
@@ -95,12 +95,12 @@ OpenAI 路径保留客户端 Responses wire 语义,上游事件字节在 HTTP
|
||||
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
|
||||
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
|
||||
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, expectedConfigRevision, data }` | 导入或按上游身份更新账号 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId, expectedConfigRevision }` | 手工刷新账号 credential |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新账号 credential |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId, expectedConfigRevision }` | 启用账号 |
|
||||
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId, expectedConfigRevision }` | 禁用账号 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds, expectedConfigRevision }` | 批量删除 1–200 个账号 |
|
||||
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId }` | 启用账号 |
|
||||
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId }` | 禁用账号 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 立即刷新该账号额度 |
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
@@ -150,7 +150,6 @@ OpenAI rotation 请求字段为:
|
||||
{
|
||||
"provider": "openai",
|
||||
"accountId": "acct_...",
|
||||
"expectedConfigRevision": 1,
|
||||
"expectedCredentialRevision": 1,
|
||||
"accessToken": "...",
|
||||
"refreshToken": "..."
|
||||
@@ -162,7 +161,6 @@ OAuth start 使用:
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "account name",
|
||||
"accountId": null,
|
||||
"expectedCredentialRevision": null
|
||||
@@ -179,18 +177,17 @@ OAuth start 使用:
|
||||
| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建绑定 Provider 的 Client Key |
|
||||
| `GET` | `/api/admin/client-keys/reveal` | `id` | 显式读取完整明文 Key |
|
||||
| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、Provider 和限额 |
|
||||
| `POST` | `/api/admin/client-keys/enable` | `{ id, expectedConfigRevision }` | 启用 |
|
||||
| `POST` | `/api/admin/client-keys/disable` | `{ id, expectedConfigRevision }` | 禁用 |
|
||||
| `POST` | `/api/admin/client-keys/delete` | `{ id, expectedConfigRevision }` | 删除 |
|
||||
| `POST` | `/api/admin/client-keys/enable` | `{ id }` | 启用 |
|
||||
| `POST` | `/api/admin/client-keys/disable` | `{ id }` | 禁用 |
|
||||
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
|
||||
|
||||
创建字段为 `expectedConfigRevision`、`name`、可选 `label`、`providerKind`、`maxConcurrency`、
|
||||
`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
|
||||
创建字段为 `name`、可选 `label`、`providerKind`、`maxConcurrency`、`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
|
||||
|
||||
## 7. 运行设置
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/admin/settings` | 读取运行设置与 `configRevision` |
|
||||
| `GET` | `/api/admin/settings` | 读取运行设置 |
|
||||
| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 |
|
||||
| `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 |
|
||||
| `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key |
|
||||
@@ -199,7 +196,6 @@ OAuth start 使用:
|
||||
设置更新字段包括:
|
||||
|
||||
```text
|
||||
expectedConfigRevision
|
||||
modelMappings
|
||||
refreshMarginSeconds
|
||||
refreshConcurrency
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 1. 系统边界
|
||||
|
||||
Codex Proxy RS 是单进程、可多副本部署的多 Provider AI 网关:
|
||||
Codex Proxy RS 是单进程、单副本部署的多 Provider AI 网关:
|
||||
|
||||
- 客户端面提供 OpenAI Responses 兼容的 JSON、SSE、WebSocket 与模型目录协议。
|
||||
- 管理面提供 `/api/admin/*` 和 Vue 静态管理端。
|
||||
@@ -15,6 +15,8 @@ Codex Proxy RS 是单进程、可多副本部署的多 Provider AI 网关:
|
||||
- PostgreSQL 是业务与配置事实的唯一权威存储。
|
||||
- Redis 只保存可丢失、可重建或有自然过期时间的协调状态。
|
||||
|
||||
部署假定:单副本。带 lease 的周期任务只做单周期互斥而非跨周期 leader 选举,N 副本会让同一任务最坏以 N 倍频率执行(见第 10 节);自更新只替换并重启处理该请求的那个副本,其余副本停留在旧版本。不要以多副本方式运行本网关。
|
||||
|
||||
Gateway Engine 不识别具体 Provider 协议;Provider 不拥有客户端 admission、跨 Provider 路由或业务重试预算。
|
||||
|
||||
## 2. Workspace 与依赖方向
|
||||
@@ -112,11 +114,10 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
|
||||
|
||||
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
|
||||
|
||||
## 7. 控制面与 revision
|
||||
## 7. 控制面与内部 revision
|
||||
|
||||
管理写入使用 `expected_config_revision` 做全局乐观并发控制。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中:
|
||||
Admin API 不要求客户端提交配置 revision,也不向客户端暴露配置 revision。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中:
|
||||
|
||||
- 校验 expected revision;
|
||||
- 执行业务 mutation;
|
||||
- 推进 `runtime_settings.config_revision`;
|
||||
- 写入脱敏 `admin_audit_events`。
|
||||
|
||||
@@ -1,7 +1,181 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface AccountQuotaWindow {
|
||||
key: string
|
||||
group: string
|
||||
windowSeconds: number | null
|
||||
labelDisplay: string
|
||||
usedPercent: number | null
|
||||
usedPercentDisplay: string
|
||||
localUsage?: unknown
|
||||
resetAtDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountQuota {
|
||||
refreshedAtDisplay: string
|
||||
windows: AccountQuotaWindow[]
|
||||
}
|
||||
|
||||
export interface AccountCurrencyCost {
|
||||
currency: string
|
||||
estimatedAmount: string
|
||||
estimatedAmountDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountModelUsage {
|
||||
model: string
|
||||
requestCount: number
|
||||
requestCountDisplay: string
|
||||
successRate: number | null
|
||||
successRateDisplay: string
|
||||
inputTokens: number | null
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number | null
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number | null
|
||||
cachedTokensDisplay: string
|
||||
imageInputTokens: number | null
|
||||
imageInputTokensDisplay: string
|
||||
imageOutputTokens: number | null
|
||||
imageOutputTokensDisplay: string
|
||||
imageRequestCount: number
|
||||
imageRequestCountDisplay: string
|
||||
imageRequestFailedCount: number
|
||||
imageRequestFailedCountDisplay: string
|
||||
totalTokens: number | null
|
||||
totalTokensDisplay: string
|
||||
billingAmountUsd: string | null
|
||||
billingAmountUsdDisplay: string
|
||||
costEstimateStatus: string
|
||||
knownCostCount: number
|
||||
partialCostCount: number
|
||||
unknownCostCount: number
|
||||
costs: AccountCurrencyCost[]
|
||||
lastUsedAt: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountUsage {
|
||||
requestCount: number | null
|
||||
requestCountDisplay: string
|
||||
inputTokens: number | null
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number | null
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number | null
|
||||
cachedTokensDisplay: string
|
||||
imageInputTokens: number | null
|
||||
imageInputTokensDisplay: string
|
||||
imageOutputTokens: number | null
|
||||
imageOutputTokensDisplay: string
|
||||
imageRequestCount: number | null
|
||||
imageRequestCountDisplay: string
|
||||
imageRequestFailedCount: number | null
|
||||
imageRequestFailedCountDisplay: string
|
||||
totalTokens: number | null
|
||||
totalTokensDisplay: string
|
||||
createdTokens: number | null
|
||||
createdTokensDisplay: string
|
||||
readTokens: number | null
|
||||
readTokensDisplay: string
|
||||
lastUsedAt: string | null
|
||||
lastUsedAtDisplay: string
|
||||
costEstimateStatus: string
|
||||
knownCostCount: number | null
|
||||
partialCostCount: number | null
|
||||
unknownCostCount: number | null
|
||||
costs: AccountCurrencyCost[]
|
||||
models: AccountModelUsage[]
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
resourceRef: string
|
||||
email: string | null
|
||||
accountId: string | null
|
||||
userId: string | null
|
||||
label: string | null
|
||||
planType: string | null
|
||||
authenticationKind: string
|
||||
hasRefreshToken: boolean
|
||||
status: string
|
||||
displayStatus: string
|
||||
tokenRefreshing: boolean
|
||||
availability: string
|
||||
enabled: boolean
|
||||
credentialRevision: number
|
||||
stateRevision: number | null
|
||||
accessTokenExpiresAt: string | null
|
||||
accessTokenExpiresAtDisplay: string | null
|
||||
refreshTokenExpiresAt: string | null
|
||||
nextRefreshAt: string | null
|
||||
addedAt: string
|
||||
addedAtDisplay: string
|
||||
updatedAt: string
|
||||
updatedAtDisplay: string
|
||||
quota: AccountQuota
|
||||
usage: AccountUsage
|
||||
}
|
||||
|
||||
export interface AccountPageMeta {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
total: number
|
||||
active: number
|
||||
quotaExhausted: number
|
||||
unavailable: number
|
||||
}
|
||||
|
||||
export interface AccountListResponse {
|
||||
items: Account[]
|
||||
page: AccountPageMeta
|
||||
summary: AccountSummary
|
||||
}
|
||||
|
||||
export interface AccountRefreshResponse {
|
||||
account: Account
|
||||
result?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AccountQuotaResponse {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export interface AccountModelsResponse {
|
||||
models: Array<{ id: string, label: string }>
|
||||
}
|
||||
|
||||
export interface AccountImportResponse {
|
||||
importedCount: number
|
||||
accountIds: string[]
|
||||
}
|
||||
|
||||
export interface AccountMutationResponse {
|
||||
accountId: string
|
||||
credentialRevision?: number
|
||||
}
|
||||
|
||||
export interface AccountDeletionResponse {
|
||||
deletedCount: number
|
||||
accountIds: string[]
|
||||
}
|
||||
|
||||
export interface AccountOAuthStartResponse {
|
||||
flowId: string
|
||||
authorizationUrl: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export function getAccounts(data: object) {
|
||||
return request({
|
||||
return request<AccountListResponse>({
|
||||
url: '/api/admin/accounts',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -9,7 +183,7 @@ export function getAccounts(data: object) {
|
||||
}
|
||||
|
||||
export function exportAccounts(data: object) {
|
||||
return request({
|
||||
return request<unknown>({
|
||||
url: '/api/admin/accounts/export',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -17,7 +191,7 @@ export function exportAccounts(data: object) {
|
||||
}
|
||||
|
||||
export function refreshAccount(data: object) {
|
||||
return request({
|
||||
return request<AccountRefreshResponse>({
|
||||
url: '/api/admin/accounts/refresh',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -25,7 +199,7 @@ export function refreshAccount(data: object) {
|
||||
}
|
||||
|
||||
export function getAccountQuota(data: object) {
|
||||
return request({
|
||||
return request<AccountQuotaResponse>({
|
||||
url: '/api/admin/accounts/quota',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -33,7 +207,7 @@ export function getAccountQuota(data: object) {
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(data: object) {
|
||||
return request({
|
||||
return request<AccountQuotaResponse>({
|
||||
url: '/api/admin/accounts/quota/refresh',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -41,7 +215,7 @@ export function refreshAccountQuota(data: object) {
|
||||
}
|
||||
|
||||
export function getAccountModels(data: object) {
|
||||
return request({
|
||||
return request<AccountModelsResponse>({
|
||||
url: '/api/admin/accounts/models',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -49,7 +223,7 @@ export function getAccountModels(data: object) {
|
||||
}
|
||||
|
||||
export function refreshAccountModels(data: object) {
|
||||
return request({
|
||||
return request<AccountModelsResponse>({
|
||||
url: '/api/admin/accounts/models/refresh',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -57,7 +231,7 @@ export function refreshAccountModels(data: object) {
|
||||
}
|
||||
|
||||
export function importAccounts(data: object) {
|
||||
return request({
|
||||
return request<AccountImportResponse>({
|
||||
url: '/api/admin/accounts/import',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -65,7 +239,7 @@ export function importAccounts(data: object) {
|
||||
}
|
||||
|
||||
export function enableAccount(data: object) {
|
||||
return request({
|
||||
return request<AccountMutationResponse>({
|
||||
url: '/api/admin/accounts/enable',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -73,7 +247,7 @@ export function enableAccount(data: object) {
|
||||
}
|
||||
|
||||
export function disableAccount(data: object) {
|
||||
return request({
|
||||
return request<AccountMutationResponse>({
|
||||
url: '/api/admin/accounts/disable',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -81,7 +255,7 @@ export function disableAccount(data: object) {
|
||||
}
|
||||
|
||||
export function deleteAccounts(data: object) {
|
||||
return request({
|
||||
return request<AccountDeletionResponse>({
|
||||
url: '/api/admin/accounts/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -89,7 +263,7 @@ export function deleteAccounts(data: object) {
|
||||
}
|
||||
|
||||
export function startAccountOAuth(data: object) {
|
||||
return request({
|
||||
return request<AccountOAuthStartResponse>({
|
||||
url: '/api/admin/accounts/oauth/start',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -97,7 +271,7 @@ export function startAccountOAuth(data: object) {
|
||||
}
|
||||
|
||||
export function completeAccountOAuth(data: object) {
|
||||
return request({
|
||||
return request<AccountMutationResponse>({
|
||||
url: '/api/admin/accounts/oauth/complete',
|
||||
method: 'POST',
|
||||
data,
|
||||
|
||||
@@ -1,7 +1,42 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
label: string | null
|
||||
providerKind: string
|
||||
prefix: string
|
||||
enabled: boolean
|
||||
maxConcurrency: number
|
||||
requestsPerMinute: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
export interface ApiKeyListResponse {
|
||||
items: ApiKey[]
|
||||
nextCursor: string | null
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateResponse {
|
||||
id: string
|
||||
prefix: string
|
||||
plaintextKey: string
|
||||
}
|
||||
|
||||
export interface ApiKeyRevealResponse {
|
||||
id: string
|
||||
plaintextKey: string
|
||||
}
|
||||
|
||||
export interface ApiKeyMutationResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function getApiKeys(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyListResponse>({
|
||||
url: '/api/admin/client-keys',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -9,7 +44,7 @@ export function getApiKeys(data: object) {
|
||||
}
|
||||
|
||||
export function createApiKey(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyCreateResponse>({
|
||||
url: '/api/admin/client-keys/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -17,7 +52,7 @@ export function createApiKey(data: object) {
|
||||
}
|
||||
|
||||
export function revealApiKey(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyRevealResponse>({
|
||||
url: '/api/admin/client-keys/reveal',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -25,7 +60,7 @@ export function revealApiKey(data: object) {
|
||||
}
|
||||
|
||||
export function deleteApiKey(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -33,7 +68,7 @@ export function deleteApiKey(data: object) {
|
||||
}
|
||||
|
||||
export function disableApiKey(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/disable',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -41,7 +76,7 @@ export function disableApiKey(data: object) {
|
||||
}
|
||||
|
||||
export function enableApiKey(data: object) {
|
||||
return request({
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/enable',
|
||||
method: 'POST',
|
||||
data,
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface LoginResponse {
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function login(data: object) {
|
||||
return request({
|
||||
return request<LoginResponse>({
|
||||
url: '/api/admin/auth/login',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -9,14 +21,14 @@ export function login(data: object) {
|
||||
}
|
||||
|
||||
export function getAuthStatus() {
|
||||
return request({
|
||||
return request<AuthStatusResponse>({
|
||||
url: '/api/admin/auth/status',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request({
|
||||
return request<LogoutResponse>({
|
||||
url: '/api/admin/auth/logout',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,214 @@
|
||||
import type { UsageRecord } from './usage'
|
||||
import request from '../request'
|
||||
|
||||
export type DashboardTrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
export type DashboardHealthStatus
|
||||
= 'future' | 'no_data' | 'unavailable' | 'unstable' | 'low_sample' | 'stable'
|
||||
|
||||
export interface DashboardTrendPoint {
|
||||
time: string
|
||||
bucket: string
|
||||
label: string
|
||||
requests: string
|
||||
requestsValue: number
|
||||
inputTokens: string
|
||||
inputTokensValue: number
|
||||
outputTokens: string
|
||||
outputTokensValue: number
|
||||
cachedTokens: string
|
||||
cachedTokensValue: number
|
||||
cacheHitRateValue: number
|
||||
tokensValue: number
|
||||
errors: string
|
||||
errorsValue: number
|
||||
latency: string
|
||||
latencyValue: number | null
|
||||
firstTokenLatency: string
|
||||
firstTokenLatencyValue: number | null
|
||||
maxLatency: string
|
||||
maxLatencyValue: number | null
|
||||
minLatency: string
|
||||
minLatencyValue: number | null
|
||||
successRate: string
|
||||
successRateValue: number | null
|
||||
}
|
||||
|
||||
export interface DashboardTrendSummaryItem {
|
||||
label: string
|
||||
value: string
|
||||
ratio: string | null
|
||||
}
|
||||
|
||||
export interface DashboardTrendResponse {
|
||||
kind: DashboardTrendKind
|
||||
points: DashboardTrendPoint[]
|
||||
summary: DashboardTrendSummaryItem[]
|
||||
}
|
||||
|
||||
export interface DashboardCards {
|
||||
credentials: {
|
||||
total: string
|
||||
totalValue: number
|
||||
available: string
|
||||
availableValue: number
|
||||
unavailable: string
|
||||
unavailableValue: number
|
||||
}
|
||||
traffic: {
|
||||
todayRequests: string
|
||||
todayRequestsValue: number
|
||||
yesterdayRequestsValue: number
|
||||
totalRequests: string
|
||||
}
|
||||
tokens: {
|
||||
todayTokens: string
|
||||
todayTokensValue: number
|
||||
yesterdayTokensValue: number
|
||||
totalTokens: string
|
||||
totalBillingAmountUsd: string
|
||||
}
|
||||
cache: {
|
||||
todayHitRate: string
|
||||
todayHitRateValue: number | null
|
||||
yesterdayHitRateValue: number | null
|
||||
totalHitRate: string
|
||||
totalCachedTokens: string
|
||||
averageFirstTokenLatencyMs: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardHealthTimelinePoint {
|
||||
time: string
|
||||
status: DashboardHealthStatus
|
||||
reliabilityDisplay: string
|
||||
successRequests: number
|
||||
failedRequests: number
|
||||
cancelledRequests: number
|
||||
incompleteRequests: number
|
||||
callerErrorRequests: number
|
||||
}
|
||||
|
||||
export interface DashboardHealthTimeline {
|
||||
title: string
|
||||
description: string
|
||||
reliabilityDisplay: string
|
||||
status: DashboardHealthStatus
|
||||
successRequests: number
|
||||
failedRequests: number
|
||||
cancelledRequests: number
|
||||
incompleteRequests: number
|
||||
callerErrorRequests: number
|
||||
points: DashboardHealthTimelinePoint[]
|
||||
}
|
||||
|
||||
export interface DashboardWireProfile {
|
||||
provider: string
|
||||
product: string
|
||||
version: string
|
||||
build?: string
|
||||
target: {
|
||||
osType: string
|
||||
osVersion: string
|
||||
arch: string
|
||||
terminal: string
|
||||
}
|
||||
userAgent: string
|
||||
attributes: Array<{ label: string, value: string }>
|
||||
verifiedAt?: string
|
||||
release?: {
|
||||
status: 'unchecked' | 'aligned' | 'review_required' | 'check_failed'
|
||||
checkedAt?: string
|
||||
latestVersion?: string
|
||||
latestBuild?: string
|
||||
publishedAt?: string
|
||||
minimumSystemVersion?: string
|
||||
hardwareRequirements?: string
|
||||
downloadUrl?: string
|
||||
downloadSize?: number
|
||||
signaturePresent?: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardAccountRequestBucket {
|
||||
bucketStart: string
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface DashboardAccountUsage {
|
||||
id: string
|
||||
provider: string
|
||||
authenticationKind: string
|
||||
email: string
|
||||
planType: string | null
|
||||
tokens: string
|
||||
requestCount: number
|
||||
requestBuckets: DashboardAccountRequestBucket[]
|
||||
quotaUsedPercent: number | null
|
||||
lastUsed: string
|
||||
}
|
||||
|
||||
export interface DashboardCredentialUsage {
|
||||
id: string
|
||||
displayName: string
|
||||
planType: string | null
|
||||
tokens: string
|
||||
tokensValue: number | null
|
||||
lastUsed: string
|
||||
provider: string
|
||||
availability: string
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface DashboardPoolSummary {
|
||||
total: number
|
||||
active: number
|
||||
expired: number
|
||||
quotaExhausted: number
|
||||
refreshing: number | null
|
||||
disabled: number
|
||||
banned: number
|
||||
}
|
||||
|
||||
export interface DashboardCapacityInfo {
|
||||
maxConcurrentPerAccount: number
|
||||
totalSlots: number
|
||||
usedSlots: number | null
|
||||
availableSlots: number | null
|
||||
}
|
||||
|
||||
export interface DashboardRequestMetrics {
|
||||
requestCount: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
cancelledCount: number
|
||||
incompleteCount: number
|
||||
callerErrorCount: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
cacheWriteTokens: number
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export interface DashboardSummaryResponse {
|
||||
cards: DashboardCards
|
||||
trend: DashboardTrendResponse
|
||||
healthTimeline: DashboardHealthTimeline
|
||||
wireProfiles: DashboardWireProfile[]
|
||||
accountUsage: DashboardAccountUsage[]
|
||||
credentialUsage: DashboardCredentialUsage[]
|
||||
usageRecords: UsageRecord[]
|
||||
poolSummary: DashboardPoolSummary
|
||||
capacityInfo: DashboardCapacityInfo
|
||||
rotationStrategy: string
|
||||
logicalRequests: DashboardRequestMetrics
|
||||
}
|
||||
|
||||
export function getDashboardSummary(data: object) {
|
||||
return request({
|
||||
return request<DashboardSummaryResponse>({
|
||||
url: '/api/admin/dashboard/summary',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -9,7 +216,7 @@ export function getDashboardSummary(data: object) {
|
||||
}
|
||||
|
||||
export function getDashboardTrend(data: object) {
|
||||
return request({
|
||||
return request<DashboardTrendResponse>({
|
||||
url: '/api/admin/dashboard/trend',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import request from '../request'
|
||||
|
||||
export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky'
|
||||
|
||||
export interface RuntimeSettings {
|
||||
modelMappings: Record<string, string>
|
||||
refreshMarginSeconds: number
|
||||
refreshConcurrency: number
|
||||
maxConcurrentPerAccount: number
|
||||
requestIntervalMs: number
|
||||
rotationStrategy: RotationStrategy
|
||||
usageRetentionDays: number
|
||||
opsEventRetentionDays: number
|
||||
auditRetentionDays: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminApiKeyStatus {
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export interface RegeneratedAdminApiKey {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface DeletedAdminApiKey {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
return request({
|
||||
return request<RuntimeSettings>({
|
||||
url: '/api/admin/settings',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSettings(data: object) {
|
||||
return request({
|
||||
return request<RuntimeSettings>({
|
||||
url: '/api/admin/settings/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -16,21 +43,21 @@ export function updateSettings(data: object) {
|
||||
}
|
||||
|
||||
export function getAdminApiKeyStatus() {
|
||||
return request({
|
||||
return request<AdminApiKeyStatus>({
|
||||
url: '/api/admin/settings/admin-api-key',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function regenerateAdminApiKey() {
|
||||
return request({
|
||||
return request<RegeneratedAdminApiKey>({
|
||||
url: '/api/admin/settings/admin-api-key/regenerate',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAdminApiKey() {
|
||||
return request({
|
||||
return request<DeletedAdminApiKey>({
|
||||
url: '/api/admin/settings/admin-api-key/delete',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface SystemVersion {
|
||||
version: string
|
||||
gitSha: string
|
||||
buildTime: string
|
||||
deploymentMode: string
|
||||
deploymentModeLabel: string
|
||||
updateChannel: string
|
||||
latestVersion: string
|
||||
hasUpdate: boolean
|
||||
updateCached: boolean
|
||||
updateWarning: string | null
|
||||
}
|
||||
|
||||
export interface SystemUpdateDetail {
|
||||
currentVersion: string
|
||||
latestVersion: string
|
||||
hasUpdate: boolean
|
||||
deploymentMode: string
|
||||
deploymentModeLabel: string
|
||||
buildType: string
|
||||
buildTypeLabel: string
|
||||
releaseUrl: string | null
|
||||
notes: string | null
|
||||
cached: boolean
|
||||
updateSupported: boolean
|
||||
unsupportedReason: string | null
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
export interface SystemUpdateAccepted {
|
||||
operationId: string
|
||||
deploymentMode: string
|
||||
message: string
|
||||
needRestart: boolean
|
||||
targetVersion: string
|
||||
}
|
||||
|
||||
export interface SystemRestartAccepted {
|
||||
message: string
|
||||
operationId: string
|
||||
}
|
||||
|
||||
export function getSystemVersion(timeout = 0) {
|
||||
return request({
|
||||
return request<SystemVersion>({
|
||||
url: '/api/admin/system/version',
|
||||
method: 'GET',
|
||||
...(timeout ? { timeout } : {}),
|
||||
@@ -9,7 +51,7 @@ export function getSystemVersion(timeout = 0) {
|
||||
}
|
||||
|
||||
export function getSystemUpdateDetail(data: object) {
|
||||
return request({
|
||||
return request<SystemUpdateDetail>({
|
||||
url: '/api/admin/system/update/detail',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -17,7 +59,7 @@ export function getSystemUpdateDetail(data: object) {
|
||||
}
|
||||
|
||||
export function performSystemUpdate(data: object) {
|
||||
return request({
|
||||
return request<SystemUpdateAccepted>({
|
||||
url: '/api/admin/system/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -25,7 +67,7 @@ export function performSystemUpdate(data: object) {
|
||||
}
|
||||
|
||||
export function restartSystem() {
|
||||
return request({
|
||||
return request<SystemRestartAccepted>({
|
||||
url: '/api/admin/system/restart',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,331 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface UsagePageMeta {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface UsageTokenDetails {
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cachedTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
reasoningTokens: number | null
|
||||
imageInputTokens: number | null
|
||||
imageOutputTokens: number | null
|
||||
totalTokens: number | null
|
||||
inputTokensDisplay: string
|
||||
outputTokensDisplay: string
|
||||
cachedTokensDisplay: string
|
||||
cacheWriteTokensDisplay: string
|
||||
reasoningTokensDisplay: string
|
||||
imageInputTokensDisplay: string
|
||||
imageOutputTokensDisplay: string
|
||||
totalTokensDisplay: string
|
||||
}
|
||||
|
||||
export interface UsageCost {
|
||||
currency: string
|
||||
estimatedAmount: string
|
||||
}
|
||||
|
||||
export interface UsageCostCoverage {
|
||||
known: number
|
||||
partial: number
|
||||
unknown: number
|
||||
notBillable: number
|
||||
}
|
||||
|
||||
export interface UsageBilling {
|
||||
inputAmountDisplay: string
|
||||
outputAmountDisplay: string
|
||||
cacheReadAmountDisplay: string
|
||||
cacheWriteAmountDisplay: string
|
||||
standardAmountDisplay: string
|
||||
totalAmountDisplay: string
|
||||
inputPriceDisplay: string
|
||||
outputPriceDisplay: string
|
||||
cacheReadPriceDisplay: string
|
||||
cacheWritePriceDisplay: string
|
||||
serviceTierDisplay: string
|
||||
multiplierDisplay: string
|
||||
}
|
||||
|
||||
export interface UsageLatencyDetails {
|
||||
transportDecisionWaitMs?: number
|
||||
wsConnectMs?: number
|
||||
upstreamHeadersMs?: number
|
||||
firstEventMs?: number
|
||||
firstReasoningMs?: number
|
||||
firstTextMs?: number
|
||||
firstTokenMs?: number
|
||||
openaiProcessingMs?: number
|
||||
}
|
||||
|
||||
// 已知字段之外还会平铺 Provider 安全观测字段,因此保留索引签名。
|
||||
export interface UsageRecordMetadata {
|
||||
[key: string]: unknown
|
||||
protocol: string
|
||||
logicalOutcome: string
|
||||
attemptCount: number
|
||||
requestedModel: string
|
||||
upstreamModel?: string
|
||||
clientIp?: string
|
||||
userAgent?: string
|
||||
reasoningEffort?: string
|
||||
reasoningPreset?: string
|
||||
compact: boolean
|
||||
requestKind?: string
|
||||
subagentKind?: string
|
||||
transport?: string
|
||||
httpVersion?: string
|
||||
clientStatusCode?: number
|
||||
upstreamStatusCode?: number
|
||||
responseId?: string
|
||||
upstreamRequestId?: string
|
||||
websocketPool: { kind: string } | null
|
||||
imageGenerationRequested: boolean
|
||||
imageGenerationSucceeded: boolean | null
|
||||
latencyDetails: UsageLatencyDetails
|
||||
}
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string
|
||||
requestId: string
|
||||
clientApiKeyId: string | null
|
||||
kind: string
|
||||
provider: string | null
|
||||
authenticationKind: string | null
|
||||
accountId: string | null
|
||||
accountEmail: string | null
|
||||
route: string
|
||||
model: string
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
statusCode: number | null
|
||||
transport: string | null
|
||||
attemptIndex: number | null
|
||||
attemptCount: number
|
||||
responseId: string | null
|
||||
upstreamRequestId: string | null
|
||||
latencyMs: number | null
|
||||
firstTokenMs: number | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cachedTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
reasoningTokens: number | null
|
||||
imageInputTokens: number | null
|
||||
imageOutputTokens: number | null
|
||||
message: string
|
||||
metadata: UsageRecordMetadata
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
clientIp: string | null
|
||||
userAgent: string | null
|
||||
reasoningEffort: string | null
|
||||
reasoningPreset: string | null
|
||||
compact: boolean | null
|
||||
requestKind: string | null
|
||||
subagentKind: string | null
|
||||
tokenDetails: UsageTokenDetails
|
||||
billing: UsageBilling | null
|
||||
costs: UsageCost[]
|
||||
costCoverage: UsageCostCoverage
|
||||
firstTokenLatencyMs: number | null
|
||||
firstTokenLatencyMsDisplay: string
|
||||
latencyMsDisplay: string
|
||||
logicalOutcome: string
|
||||
}
|
||||
|
||||
export interface UsageAttempt {
|
||||
id: string
|
||||
attemptIndex: number
|
||||
trigger: string
|
||||
provider: string
|
||||
model: string
|
||||
transport: string
|
||||
sendState: string
|
||||
outcome: string
|
||||
downstreamCommitted: boolean
|
||||
statusCode: number | null
|
||||
providerErrorCode: string | null
|
||||
failureClass: string | null
|
||||
costEstimateStatus: string
|
||||
estimatedCostAmount: string | null
|
||||
estimatedCostCurrency: string | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cachedTokens: number | null
|
||||
totalTokens: number | null
|
||||
firstTokenMs: number | null
|
||||
latencyMs: number | null
|
||||
credentialName: string | null
|
||||
accountEmail: string | null
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export type UsageRecordDetail = UsageRecord & {
|
||||
attempts: UsageAttempt[]
|
||||
}
|
||||
|
||||
export interface UsageRecordsResponse {
|
||||
items: UsageRecord[]
|
||||
page: UsagePageMeta
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface OpsErrorMetadata {
|
||||
source: string
|
||||
component: string
|
||||
attemptId: string | null
|
||||
accountLabel: string | null
|
||||
}
|
||||
|
||||
export interface OpsError {
|
||||
id: string
|
||||
requestId: string | null
|
||||
clientApiKeyId: string | null
|
||||
kind: string
|
||||
provider: string | null
|
||||
accountId: string | null
|
||||
route: string
|
||||
model: string | null
|
||||
clientStatusCode: number | null
|
||||
upstreamStatusCode: number | null
|
||||
transport: string | null
|
||||
attemptIndex: number | null
|
||||
failureClass: string
|
||||
responseId: string | null
|
||||
upstreamRequestId: string | null
|
||||
latencyMs: number | null
|
||||
message: string
|
||||
metadata: OpsErrorMetadata
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
}
|
||||
|
||||
export interface OpsErrorsResponse {
|
||||
items: OpsError[]
|
||||
page: UsagePageMeta
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface UsageSummaryResponse {
|
||||
totalRequests: string
|
||||
inputTokens: string
|
||||
outputTokens: string
|
||||
cachedTokens: string
|
||||
cacheWriteTokens: string
|
||||
totalTokens: string
|
||||
averageLatencyMs: string
|
||||
}
|
||||
|
||||
export interface UsageOverviewHealthPoint {
|
||||
bucket: string
|
||||
label: string
|
||||
successRequests: number
|
||||
failedRequests: number
|
||||
cancelledRequests: number
|
||||
incompleteRequests: number
|
||||
callerErrorRequests: number
|
||||
errorRate: number
|
||||
}
|
||||
|
||||
export interface UsageOverviewHealth {
|
||||
totalRequests: number
|
||||
successRequests: number
|
||||
failedRequests: number
|
||||
cancelledRequests: number
|
||||
incompleteRequests: number
|
||||
callerErrorRequests: number
|
||||
successRate: number
|
||||
requestChangeRate: number | null
|
||||
successRateChange: number | null
|
||||
points: UsageOverviewHealthPoint[]
|
||||
}
|
||||
|
||||
export interface UsageOverviewPerformancePoint {
|
||||
bucket: string
|
||||
label: string
|
||||
latencyP50Ms: number | null
|
||||
latencyP95Ms: number | null
|
||||
latencyP99Ms: number | null
|
||||
firstTokenP50Ms: number | null
|
||||
firstTokenP95Ms: number | null
|
||||
firstTokenP99Ms: number | null
|
||||
}
|
||||
|
||||
export interface UsageOverviewPerformance {
|
||||
latencyP50Ms: number | null
|
||||
latencyP95Ms: number | null
|
||||
latencyP99Ms: number | null
|
||||
firstTokenP50Ms: number | null
|
||||
firstTokenP95Ms: number | null
|
||||
firstTokenP99Ms: number | null
|
||||
latencyCoverage: number
|
||||
firstTokenCoverage: number
|
||||
points: UsageOverviewPerformancePoint[]
|
||||
}
|
||||
|
||||
export interface UsageOverviewCostPoint {
|
||||
bucket: string
|
||||
label: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
totalTokens: number
|
||||
estimatedCost: string | null
|
||||
standardCost: string | null
|
||||
cachedTokenRate: number
|
||||
cacheHitRequestRate: number | null
|
||||
}
|
||||
|
||||
export interface UsageOverviewCost {
|
||||
estimatedCost: string | null
|
||||
standardCost: string | null
|
||||
costPerRequest: string | null
|
||||
tokensPerRequest: number
|
||||
cachedTokenRate: number
|
||||
cacheHitRequestRate: number | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedTokens: number
|
||||
totalTokens: number
|
||||
points: UsageOverviewCostPoint[]
|
||||
}
|
||||
|
||||
export interface UsageInsightsOverviewResponse {
|
||||
granularity: string
|
||||
health: UsageOverviewHealth
|
||||
performance: UsageOverviewPerformance
|
||||
cost: UsageOverviewCost
|
||||
}
|
||||
|
||||
export interface UsageDiagnosticItem {
|
||||
name: string
|
||||
requestCount: number
|
||||
successCount: number
|
||||
errorCount: number
|
||||
errorRate: number
|
||||
requestShare: number
|
||||
averageLatencyMs: number | null
|
||||
estimatedCost: string | null
|
||||
attemptCount: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export interface UsageDiagnosticsResponse {
|
||||
dimension: string
|
||||
items: UsageDiagnosticItem[]
|
||||
}
|
||||
|
||||
export function getUsageRecords(data: object) {
|
||||
return request({
|
||||
return request<UsageRecordsResponse>({
|
||||
url: '/api/admin/usage/records',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -9,7 +333,7 @@ export function getUsageRecords(data: object) {
|
||||
}
|
||||
|
||||
export function getOpsErrors(data: object) {
|
||||
return request({
|
||||
return request<OpsErrorsResponse>({
|
||||
url: '/api/admin/operations/errors',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -17,7 +341,7 @@ export function getOpsErrors(data: object) {
|
||||
}
|
||||
|
||||
export function getUsageRecordDetail(data: object) {
|
||||
return request({
|
||||
return request<UsageRecordDetail>({
|
||||
url: '/api/admin/usage/records/detail',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -25,7 +349,7 @@ export function getUsageRecordDetail(data: object) {
|
||||
}
|
||||
|
||||
export function getUsageRecordSummary(data: object) {
|
||||
return request({
|
||||
return request<UsageSummaryResponse>({
|
||||
url: '/api/admin/usage/records/summary',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -33,7 +357,7 @@ export function getUsageRecordSummary(data: object) {
|
||||
}
|
||||
|
||||
export function getUsageRecordInsightsOverview(data: object) {
|
||||
return request({
|
||||
return request<UsageInsightsOverviewResponse>({
|
||||
url: '/api/admin/usage/insights/overview',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -41,7 +365,7 @@ export function getUsageRecordInsightsOverview(data: object) {
|
||||
}
|
||||
|
||||
export function getUsageRecordInsightsDiagnostics(data: object) {
|
||||
return request({
|
||||
return request<UsageDiagnosticsResponse>({
|
||||
url: '/api/admin/usage/insights/diagnostics',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
|
||||
@@ -70,7 +70,13 @@ http.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
function isApiEnvelope(value: any) {
|
||||
interface ApiEnvelope {
|
||||
code: number
|
||||
message: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
function isApiEnvelope(value: unknown): value is ApiEnvelope {
|
||||
return (
|
||||
typeof value === 'object'
|
||||
&& value !== null
|
||||
@@ -80,14 +86,14 @@ function isApiEnvelope(value: any) {
|
||||
)
|
||||
}
|
||||
|
||||
export default async function request(config: AxiosRequestConfig) {
|
||||
const response = await http.request({
|
||||
export default async function request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
|
||||
const response = await http.request<unknown>({
|
||||
...config,
|
||||
})
|
||||
|
||||
if (isApiEnvelope(response.data)) {
|
||||
return response.data.data
|
||||
return response.data.data as T
|
||||
}
|
||||
|
||||
return response.data
|
||||
return response.data as T
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clamp, sumBy } from 'es-toolkit'
|
||||
|
||||
export type TableRow = Record<string, unknown>
|
||||
// 行类型保持结构化约束;动态列 key 的取值统一走 cellValue。
|
||||
export type TableRow = object
|
||||
|
||||
export interface BaseTableColumn<Row extends TableRow = TableRow> {
|
||||
key: string
|
||||
@@ -111,7 +112,7 @@ export function alignClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
}
|
||||
|
||||
export function cellValue(row: TableRow, key: string) {
|
||||
return row[key]
|
||||
return (row as Record<string, unknown>)[key]
|
||||
}
|
||||
|
||||
function isEmptyCellValue(value: unknown) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts" generic="Row extends Record<string, unknown> = Record<string, unknown>">
|
||||
<script setup lang="ts" generic="Row extends object = Record<string, unknown>">
|
||||
import type { BaseTableColumn, BaseTableSort, ResolvedTableColumn } from './columns'
|
||||
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
|
||||
|
||||
@@ -149,7 +149,7 @@ function getRowKey(row: Row, index: number) {
|
||||
return props.rowKey(row, index)
|
||||
}
|
||||
|
||||
const value = row[props.rowKey]
|
||||
const value = cellValue(row, props.rowKey)
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : index
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user