Compare commits

..
17 Commits
Author SHA1 Message Date
ZyyandCodex 5d29ddfffc chore: bump version to 3.0.13
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:33:55 +08:00
ZyyandCodex 45903b684b fix(admin): remove config revision fences from API writes
Operator mutations no longer require callers to carry configRevision values through accounts, client keys, provider credentials, or settings. The store and admin use cases now apply current state directly, which removes stale-revision failures from normal create, update, and delete flows.

xAI Responses catalog entries now let the upstream validate feature support instead of rejecting requests locally when optional catalog metadata is absent, matching the Build routing behavior used by grok2api.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:30:59 +08:00
zyycnandCodex a580874697 chore: bump version to 3.0.12
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 02:57:15 +08:00
zyycnandClaude 885369f19f chore(release): drop Windows target, extend stop grace, document single-replica
The Windows release shipped a zip that self-update cannot extract, so
remove the Windows platform, matrix leg, and its dead archive handling
rather than add zip support. Raise the compose stop grace period above the
serial drain plus worker-shutdown budget so Docker does not SIGKILL mid
shutdown. Record that the deployment is single-replica and why, since the
worker leader lease and self-update swap are per-replica.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:25 +08:00
zyycnandClaude 24ff339c97 fix(admin): tolerate bad records in usage billing and stop showing invalid accounts as active
One record with an unparseable provider_kind no longer makes the whole
usage list return 500; the failing record is skipped and the list still
renders. An account whose credential availability is Invalid — excluded
from scheduling — was displayed as Active in the directory; it now derives
the Expired status so it reads as unavailable, consistent with how a dead
credential surfaces to the operator.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:13 +08:00
zyycnandClaude e582eb73d2 fix(xai): preserve unknown fields, scope history sanitize, guard cache-route tools, batch selector reads
Rebuild history items with in-place Map surgery so unknown upstream fields
survive instead of evaporating through a field whitelist. Narrow history
sanitization to the known Grok-internal injection sites rather than
stripping every null and any key named phase at any depth. Ensure the
prompt-cache route's injected web_search/x_search tools can never be
actually invoked when the client sent its own tools. Batch the selector's
per-account observed-model-support and cooldown reads instead of a serial
Redis round trip each. One plan scope failing no longer aborts the whole
provider catalog refresh.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:02 +08:00
zyycnandClaude ec252b3b46 fix(openai): per-account quota sync, narrower catalog union and expiry, model presentation
One account's quota parse failure no longer aborts the whole synchronize
batch. The per-plan catalog union no longer wedges every refresh on any
presentation/metadata drift — only routing-relevant facts must agree.
compile_model_capabilities now attaches a ModelPresentation so openai
models carry display facts through /v1/models instead of fabricated
defaults. is_expired_credential requires a genuine 401/403 or structured
expiry code instead of matching the substring "unauthorized" anywhere in
the body. The request turn-state header is authoritative over the body
camelCase alias.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:25:47 +08:00
zyycnandClaude 6503f36750 fix(core): stop deadline expiry from tripping the circuit and unpin recovery account
A gateway-budget deadline was recorded as a provider Timeout that counts
against the circuit breaker, so many long streams expiring together could
wrongly open it; a deadline reached after downstream delivery has begun no
longer feeds the provider circuit. required_account was pinned for the rest
of the request after a same-account credential recovery, so a later
retryable error terminated instead of switching accounts; the pin now binds
only the immediate recovery replay and is cleared afterward, letting
subsequent errors use the remaining accounts and retry budget.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:25:47 +08:00
zyycnandClaude 2366f66f33 refactor(web): type the API layer end to end
Make request generic so the axios envelope is unwrapped as a typed value,
and thread the known response shapes through every api module and their
call sites so a wire rename surfaces as a compile error instead of silent
any. Type-only: no runtime or visual change.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:52:00 +08:00
zyycnandClaude cc0303c65e ci: drop the dead Dockerfile stage and sanitize the build-metadata image tag
Remove the release-asset build stages that nothing references. Strip the
+build semver metadata from the derived container tag, which is not a legal
tag character, while keeping the full version string where a tag is not
required.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:51 +08:00
zyycnandClaude b11eeac29e test: enforce the crate dependency DAG and widen websocket route coverage
Add a workspace-level architecture test that parses each backend crate's
manifest and asserts the layering rules the architecture doc describes —
gateway-core has no HTTP/DB/Redis/provider dependency, the providers do not
depend on each other, gateway-admin has no transport or storage crates —
and that no production src file hosts a test module. Extend the responses
websocket tests to cover the upgrade boundary: handshake routing, upgrade
precedence over authentication, and malformed-handshake rejection.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:51 +08:00
zyycnandClaude ad5f3ba997 fix(xai): tolerate unknown JWKS members and corrupt catalog cache; drop dead sync
Stop rejecting a JWKS document that carries members the struct does not
model, so a token still verifies when the IdP adds a field. Treat an
unparseable catalog cache entry as a cache miss that refetches instead of
a hard failure. Remove the unused GrokCredentialCatalogService::synchronize
cluster and its snapshot re-export.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:41 +08:00
zyycnandClaude 15455ca619 fix(openai): parse Retry-After dates, stop dropping raw frames, restrict base_url
Retry-After now accepts the HTTP-date form in addition to integer seconds.
When a raw SSE passthrough frame fails to construct, fall back to the
serialized form instead of silently dropping the event. Reject upstream
base_url values that are http or resolve to loopback/private ranges, so an
operator-supplied URL cannot point the data plane at an internal address.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:41 +08:00
zyycnandClaude 904315c209 fix(admin): clamp session ttl and de-duplicate the UTC+8 offset helper
An enormous session_ttl_minutes config value overflowed chrono when added
to the current time and panicked session creation; clamp it to a sane
maximum. Route both UTC+8 formatting call sites through a single helper.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:30 +08:00
zyycnandClaude 95d720dd86 fix(host): close self-restart and shutdown races, drop dead preview channel
Retry binding the listener on AddrInUse within a bounded window so a
self-restart handoff that races the old process releasing the port does
not exit the new process offline. Make restart() take the same operation
lock as update and rollback so a restart cannot interleave with an
in-flight update. Enable the idle notification before reading the active
count in wait_until_idle so the last connection dropping between the read
and the first poll cannot lose the wakeup and stall the full drain
timeout. Remove the unreachable preview update-channel configuration.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:30 +08:00
zyycnandClaude 74f59cc4f8 fix(core): correct attempt-observation reset, success status, and error precision
reset_uncommitted_observations left provider_processing_ms set when
discarding a failed attempt's observations, leaking one attempt's upstream
processing time into the next; clear it with the other per-attempt fields.
finish_success fabricated an upstream_status_code of 200 when none was
observed — persist the absent status as NULL instead. Give the "no active
attempt" internal state its own EngineError::NoActiveAttempt instead of
overloading EmptyRoutingPlan, and map it to an internal gateway error.
Remove the unused free function commit_requirement.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:19 +08:00
zyycnandClaude e979454457 test(xai): move release-profile tests into the integration test target
The workspace runs tests only through each crate's main integration
target (autotests = false), so the #[cfg(test)] module embedded in
transport/profile.rs never executed — the only such module left in
production src. Mirror the two release-refresh tests into
tests/transport/profile.rs and export the release service surface they
exercise.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:19:12 +08:00
114 changed files with 3539 additions and 1479 deletions
+12 -16
View File
@@ -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:**
+139
View File
@@ -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}`"
);
}
}
+6 -1
View File
@@ -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);
}
+1 -1
View File
@@ -2,7 +2,7 @@
pub mod config;
mod logging;
mod serve;
pub mod serve;
pub mod system_update;
pub mod workers;
+36 -4
View File
@@ -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();
+3 -13
View File
@@ -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),
+17 -2
View File
@@ -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(
&current,
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(
&current,
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() {
+16 -13
View File
@@ -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,
+9 -4
View File
@@ -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);
}
}
}
}
+1 -7
View File
@@ -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!(
-18
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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`。
+187 -13
View File
@@ -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,
+41 -6
View File
@@ -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,
+15 -3
View File
@@ -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',
})
+209 -2
View File
@@ -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,
+32 -5
View File
@@ -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',
})
+46 -4
View File
@@ -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',
})
+330 -6
View File
@@ -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,
+11 -5
View File
@@ -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