Compare commits

...
15 Commits
Author SHA1 Message Date
ZyyandCodex 68f05a4481 chore: bump version to 3.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:59 +08:00
ZyyandCodex bb4d178b5d docs(release): prepare v3.4.0 release notes
Document managed proxies, connection tests before saving, credential and
certificate fixes, account and interface improvements, and migration
behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:26 +08:00
ZyyandCodex 73487fd98b fix(api-keys): trim trailing zeros from budget amounts
Display budget amounts with up to two decimal places without padding
trailing zeros. Keep exact amounts in the details popover and preserve
accounting precision.

Validation: frontend lint/type checks/build and browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:20 +08:00
ZyyandCodex 66fd09eba6 feat(proxies): test connections before saving and refine controls
Separate connection testing from saving in proxy forms. Probe unsaved URLs
with the shared certificate configuration and test concurrency limit,
without changing saved proxy settings or linked accounts.

Keep saved authentication when testing an unchanged connection, use
clearer placeholders, fix action column widths, and remove the redundant
list refresh button.

Validation: 5 proxy API tests, scoped strict Clippy, rustfmt, frontend
lint/type checks/build, and browser checks passed. Codex CLI requests
through real HTTP and SOCKS5 proxies completed with matching upstream
text while Clash TUN remained enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:14 +08:00
ZyyandCodex bd152dec00 feat: integrate managed proxies and WebSocket fixes (#60)
Integrate managed account proxies and the non-streaming WebSocket fix.
Include shared CA trust, credential import protection, paginated linked
account management, and the reviewed interface refinements.

Preserve the original contribution and the separate proxy and UI commits.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:54:24 +08:00
ZyyandCodex 7090dca502 fix(ui): correct modal dragging and sidebar scrolling
Use positional offsets for modal dragging to prevent seams between table
cells. Scroll sidebar navigation independently while keeping the brand
and footer fixed, with an auto-hiding scrollbar at the outer edge.

Validation: frontend lint/type checks/build and browser checks for
modal dragging, sidebar overflow, alignment, and auto-hide passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:45 +08:00
ZyyandCodex 029f3124bf feat(proxies): paginate linked accounts and support removal
Load linked accounts on demand with shared plan metadata and guarded
removal to direct connections. Preserve credentials, scheduling settings,
and groups when clearing a proxy binding.

Align proxy forms and tables with shared components, integrate endpoint
details into outbound selectors, and consolidate probe results into the
latency column.

Validation: 11 proxy regressions, strict workspace Clippy, rustfmt,
frontend lint/type checks/build, and local browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:31 +08:00
ZyyandCodex 840707e5a1 fix(proxies): share CA trust and preserve credentials during import
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 13:07:31 +08:00
Yanshan Chen 704b652f86 feat: add managed account proxies and fix non-streaming WebSocket requests 2026-09-11 11:55:09 +08:00
ZyyandCodex 2b98839c29 fix(admin): unify plan labels and refine budget popover
Normalize provider plan display names to PascalCase while preserving raw
plan values. Replace native budget tooltips with the shared borderless
popover, retaining exact amounts and daily/weekly reset information.
Reuse the dayjs date helper for explicit Beijing time formatting.

Validation: 371 Admin/API tests, strict Clippy, rustfmt, frontend lint and
type checks passed. Local frontend and backend health checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:47:34 +08:00
ZyyandCodex 90dd5b5a57 chore: bump version to 3.3.32
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:12 +08:00
ZyyandCodex 5ad9378b33 docs(release): prepare 3.3.32 release notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:08 +08:00
ZyyandCodex 4a413f5bb7 fix(budgets): remove manual charge reconciliation
Settle only observed USD costs and treat missing costs as zero without
blocking client keys. Preserve known retry costs and idempotent settlement.
Remove reconciliation controls, endpoints, pending records, and obsolete
schema fields; migrate legacy unknown charges to zero without changing
recorded usage.

Display daily and weekly amounts with two decimal places while retaining
full precision for accounting and tooltips. Distinguish missing WebSocket
closing handshakes from TCP resets in connection diagnostics.

Validation: workspace tests with PostgreSQL/Redis, targeted regressions,
strict Clippy, rustfmt, frontend lint/build, and migration checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:13:06 +08:00
ZyyandCodex 0bab566d04 fix(xai): correct subscription plans and Codex tool handling
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 04:49:22 +08:00
ZyyandCodex 9b19cf083a feat(release): publish release notes from Markdown
Require committed, non-empty notes matching the target release version before
creating a tag. Read notes from the tagged source and place them before the
installation guide and generated release changelog.

Add the 3.3.31 feature summary and keep maintainer instructions in the release
script help and repository skill instead of the deployment README.

Validation: shell syntax and workflow lint passed. Nine isolated checks cover
local publication, stale or missing notes, and release-body rendering.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:40:00 +08:00
136 changed files with 7089 additions and 2409 deletions
+31 -9
View File
@@ -88,6 +88,13 @@ jobs:
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
@@ -643,15 +650,10 @@ jobs:
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
- name: Prepare release notes
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
@@ -672,6 +674,26 @@ jobs:
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
+1 -1
View File
@@ -21,7 +21,7 @@
## 功能
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
+2
View File
@@ -744,6 +744,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",
]
[[package]]
@@ -1433,6 +1434,7 @@ dependencies = [
"cron",
"futures",
"gateway-core",
"heck",
"hex",
"rand_core 0.6.4",
"secrecy",
+1
View File
@@ -28,6 +28,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] }
serde_json = "1"
syn = { version = "3.0.3", features = ["full", "visit"] }
tempfile = "3.27.0"
tokio-rustls = { version = "=0.26.4", default-features = false }
[lints]
workspace = true
+4 -1
View File
@@ -78,7 +78,10 @@ pub async fn run() -> Result<(), BootstrapError> {
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
(
core.account_probe(),
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
),
host.client_distribution_resolver(),
host.system_operations(),
)
@@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"proxy_probe",
"serve",
"system_update",
"workers",
+145
View File
@@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
+1
View File
@@ -19,6 +19,7 @@ bytes = "1.11.1"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
heck = "0.5.0"
hex = "0.4.3"
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
+17 -2
View File
@@ -25,7 +25,7 @@ pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -149,6 +149,7 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
@@ -163,6 +164,11 @@ pub struct AdminServices {
}
impl AdminServices {
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
}
#[must_use]
pub fn auth(&self) -> &dyn AuthService {
self.auth.as_ref()
@@ -247,10 +253,11 @@ pub async fn initialize(
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
let (probe, proxy_probe) = probes;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
@@ -290,6 +297,12 @@ pub async fn initialize(
backup_ports.object_store(),
);
let services = AdminServices {
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
snapshot.clone(),
registry.clone(),
)),
auth,
accounts,
account_groups: Arc::new(DefaultAccountGroupService::new(
@@ -316,11 +329,13 @@ pub async fn initialize(
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
backups,
@@ -221,7 +221,7 @@ pub struct UpdateAccount {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 账号更新结果。
@@ -239,7 +239,7 @@ pub struct BatchUpdateAccounts {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 批量账号更新结果。
@@ -223,19 +223,3 @@ pub struct ClientKeyMutation {
pub record: Option<ClientKeyRecord>,
pub id: ClientApiKeyId,
}
#[derive(Debug, Clone)]
pub struct UnresolvedClientCharge {
pub request_id: String,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub state: String,
}
#[derive(Debug, Clone)]
pub struct ReconcileClientCharge {
pub key_id: ClientApiKeyId,
pub request_id: String,
pub amount_usd: gateway_core::metering::Decimal,
pub reason: String,
}
@@ -10,6 +10,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
pub mod proxies;
pub mod settings;
pub mod system;
@@ -108,6 +108,7 @@ pub struct CredentialDetails {
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub document: ProviderDocument,
@@ -132,6 +133,7 @@ pub struct CredentialImportResult {
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
pub struct PrepareCredentialImport {
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub document: ProviderDocument,
}
@@ -175,6 +177,7 @@ pub struct PreparedCredentialImport {
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
pub settings: Option<AccountImportSettings>,
pub prepared: PreparedCredentialImport,
}
@@ -255,6 +258,7 @@ pub struct PendingAuthorizationMutation {
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
outbound_proxy_id: Option<String>,
}
impl PendingAuthorizationMutation {
@@ -269,6 +273,7 @@ impl PendingAuthorizationMutation {
target,
owner_binding,
outbound_proxy: None,
outbound_proxy_id: None,
}
}
@@ -299,6 +304,16 @@ impl PendingAuthorizationMutation {
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy.as_ref()
}
#[must_use]
pub fn with_outbound_proxy_id(mut self, id: Option<String>) -> Self {
self.outbound_proxy_id = id;
self
}
pub fn outbound_proxy_id(&self) -> Option<&str> {
self.outbound_proxy_id.as_deref()
}
}
impl PendingAuthorizationMutation {
@@ -338,6 +353,9 @@ impl PendingAuthorizationMutation {
Value::String(proxy.expose_url().to_owned()),
);
}
if let Some(id) = &self.outbound_proxy_id {
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
}
document
}
@@ -382,7 +400,8 @@ impl PendingAuthorizationMutation {
started_request_id: document.started_request_id,
},
)
.with_outbound_proxy(proxy))
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(document.outbound_proxy_id))
}
}
@@ -395,6 +414,8 @@ struct StoredAuthorizationMutationV1 {
started_request_id: String,
#[serde(default)]
outbound_proxy_url: Option<String>,
#[serde(default)]
outbound_proxy_id: Option<String>,
}
#[derive(Deserialize)]
@@ -418,7 +439,7 @@ pub struct StartAuthorization {
pub context: MutationContext,
pub name: String,
pub reauthorization: Option<ProviderAccountId>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// OAuth 流程启动结果。
@@ -0,0 +1,105 @@
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use super::{PageSize, Revision, account_groups::AccountGroupRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountProxySelection {
Direct,
Url(OutboundProxy),
Saved(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportProxyBinding {
pub id: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct ProxyListQuery {
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyAccountRef {
pub id: String,
pub name: String,
pub email: Option<String>,
pub provider_kind: String,
pub authentication_kind: String,
pub plan_type: Option<String>,
pub plan_type_display: Option<String>,
pub groups: Vec<AccountGroupRef>,
pub enabled: bool,
}
/// 按代理查询关联账号,分页与搜索均在存储层执行。
#[derive(Debug, Clone)]
pub struct ProxyAccountListQuery {
pub proxy_id: String,
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone)]
pub struct ProxyAccountPage {
pub items: Vec<ProxyAccountRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub id: String,
pub name: String,
pub proxy: OutboundProxy,
pub revision: Revision,
pub account_count: u64,
pub last_test_at: Option<DateTime<Utc>>,
pub last_test: Option<ProxyTestResult>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ProxyPage {
pub items: Vec<ProxyRecord>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone)]
pub struct NewProxy {
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
pub id: String,
pub revision: Revision,
pub name: String,
pub proxy: Option<OutboundProxy>,
}
#[derive(Debug, Clone)]
pub struct ProxyMutation {
pub config_revision: Revision,
pub record: ProxyRecord,
}
@@ -3,5 +3,6 @@
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod proxy;
pub mod store;
pub mod system;
@@ -8,6 +8,7 @@ use gateway_core::{
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
use heck::ToUpperCamelCase;
use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
@@ -251,7 +252,20 @@ impl ProviderAdminRegistry {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 账号页和 Dashboard 共用的套餐展示投影,不修改持久化的原始套餐值。
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
pub(crate) fn resolve_account_plan(
&self,
provider_kind: &str,
plan_type: &mut Option<String>,
quota: Option<&ProviderQuota>,
) -> Option<String> {
if let Some(quota) = quota {
quota.fill_missing_plan_type(plan_type);
}
self.plan_type_display(provider_kind, plan_type.as_deref())
}
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
pub(crate) fn plan_type_display(
&self,
provider_kind: &str,
@@ -261,10 +275,12 @@ impl ProviderAdminRegistry {
let provider = ProviderKind::new(provider_kind.to_owned())
.ok()
.and_then(|kind| self.providers.get(&kind));
Some(provider.map_or_else(
let display = provider.map_or_else(
|| plan_type.to_owned(),
|provider| provider.plan_type_display(plan_type),
))
);
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
Some(display.replace('+', " Plus ").to_upper_camel_case())
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
@@ -0,0 +1,66 @@
use async_trait::async_trait;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage>;
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn record_test(
&self,
id: &str,
revision: Revision,
result: ProxyTestResult,
context: &MutationContext,
) -> AdminStoreResult<ProxyRecord>;
}
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
pub trait ProxyImportGuard: Send + Sync {}
pub struct ProxyImportReservation {
pub binding: ImportProxyBinding,
pub guard: Box<dyn ProxyImportGuard>,
}
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
}
@@ -220,29 +220,6 @@ pub trait AuthStore: Send + Sync {
/// Client API Key 管理写入。
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
async fn unresolved_charges(
&self,
_id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<Vec<crate::model::client_keys::UnresolvedClientCharge>> {
Err(AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"client budget",
"unsupported",
))
}
async fn reconcile_charge(
&self,
_command: crate::model::client_keys::ReconcileClientCharge,
_context: &MutationContext,
) -> AdminStoreResult<()> {
Err(AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"client budget",
"unsupported",
))
}
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
async fn reveal_client_key(
@@ -405,6 +382,7 @@ pub struct AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
}
impl AdminAccountStorePorts {
@@ -413,11 +391,13 @@ impl AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
) -> Self {
Self {
accounts,
runtime,
groups,
proxies,
}
}
}
@@ -470,6 +450,11 @@ impl AdminStorePorts {
self.accounts.groups.clone()
}
#[must_use]
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
self.accounts.proxies.clone()
}
#[must_use]
pub fn auth(&self) -> Arc<dyn AuthStore> {
self.auth.clone()
@@ -280,11 +280,11 @@ impl DefaultAccountsService {
)
.await?;
let usage = quota.representative_window_usage().cloned();
quota.fill_missing_plan_type(&mut stored.account.plan_type);
Ok(AccountDirectoryItem {
plan_type_display: self.providers.plan_type_display(
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
stored.account.plan_type.as_deref(),
&mut stored.account.plan_type,
Some(&quota),
),
projection: stored.projection,
usage,
@@ -373,11 +373,11 @@ impl AccountsService for DefaultAccountsService {
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota.representative_window_usage().cloned();
quota.fill_missing_plan_type(&mut item.account.plan_type);
AccountDirectoryItem {
plan_type_display: self.providers.plan_type_display(
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
item.account.plan_type.as_deref(),
&mut item.account.plan_type,
Some(&quota),
),
usage,
account: item.account,
@@ -26,19 +26,6 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Client Key 管理服务。
#[async_trait]
pub trait ClientKeyService: Send + Sync {
async fn unresolved_charges(
&self,
_id: &ClientApiKeyId,
) -> Result<Vec<crate::model::client_keys::UnresolvedClientCharge>, AdminError> {
Err(AdminError::unavailable("client budget unavailable"))
}
async fn reconcile_charge(
&self,
_context: &MutationContext,
_command: crate::model::client_keys::ReconcileClientCharge,
) -> Result<(), AdminError> {
Err(AdminError::unavailable("client budget unavailable"))
}
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
async fn create(
@@ -77,34 +64,6 @@ impl DefaultClientKeyService {
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn unresolved_charges(
&self,
id: &ClientApiKeyId,
) -> Result<Vec<crate::model::client_keys::UnresolvedClientCharge>, AdminError> {
self.store
.unresolved_charges(id)
.await
.map_err(|error| map_store_error(error, "client budget"))
}
async fn reconcile_charge(
&self,
context: &MutationContext,
command: crate::model::client_keys::ReconcileClientCharge,
) -> Result<(), AdminError> {
if command.request_id.is_empty()
|| command.request_id.len() > 128
|| command.reason.trim().is_empty()
|| command.reason.len() > 1024
|| command.reason.chars().any(char::is_control)
{
return Err(AdminError::invalid("invalid charge reconciliation"));
}
self.store
.reconcile_charge(command, context)
.await
.map_err(|error| map_store_error(error, "client budget"))
}
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
validate_cursor(&query)?;
self.store
@@ -8,6 +8,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod xai;
@@ -109,13 +110,27 @@ async fn required_credential(
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let mut proxy = command.outbound_proxy.clone();
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
proxy = required_credential(accounts, provider_kind, account_id, resource)
@@ -126,9 +141,27 @@ async fn pending_authorization(
account_id: account_id.clone(),
}
}
None => AuthorizationMutationTarget::Create {
name: command.name.clone(),
},
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
@@ -137,7 +170,8 @@ async fn pending_authorization(
&command.context,
),
)
.with_outbound_proxy(proxy))
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
fn validate_prepared_import(
@@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync {
pub(crate) struct DefaultOpenAiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -66,11 +67,13 @@ impl DefaultOpenAiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -144,6 +158,7 @@ impl OpenAiService for DefaultOpenAiService {
.accounts
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
@@ -151,6 +166,7 @@ impl OpenAiService for DefaultOpenAiService {
)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
drop(proxy_reservation);
self.provider
.account_facts_changed(&result.credential_ids)
.await;
@@ -166,6 +182,7 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"OpenAI credential",
@@ -0,0 +1,261 @@
use std::sync::Arc;
use async_trait::async_trait;
use futures::{StreamExt, stream};
use gateway_core::{
account::{OutboundProxy, ProviderAccountId},
routing::ProviderKind,
runtime::SnapshotControl,
};
use tokio::sync::Semaphore;
use super::{map_store_error, publish_committed};
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
proxies::*,
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError>;
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
}
pub(crate) struct DefaultProxiesService {
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
test_slots: Semaphore,
}
impl DefaultProxiesService {
pub(crate) fn new(
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
probe,
snapshot,
providers,
test_slots: Semaphore::new(4),
}
}
}
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let account_id = ProviderAccountId::new(account_id.to_owned())
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
let revision = self
.store
.remove_account(proxy_id, &account_id, context)
.await
.map_err(|error| {
if error.kind() == AdminStoreErrorKind::Conflict {
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
} else {
map_store_error(error, "proxy account")
}
})?;
publish_committed(self.snapshot.as_ref(), revision).await?;
Ok(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError> {
if query.proxy_id.is_empty()
|| query.proxy_id.len() > 128
|| query.proxy_id.chars().any(char::is_control)
|| query.page == 0
|| query.search.len() > 256
|| query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("关联账号查询参数不合法"));
}
let mut page = self
.store
.list_accounts(query)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
page.items = stream::iter(page.items)
.map(|mut account| async {
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
let mut cached_quota = None;
if explicit_plan_type(account.plan_type.as_deref()).is_none()
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
&& let Ok(provider) = self.providers.require(&kind)
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
&& let Ok(quota) = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
{
cached_quota = Some(quota);
}
account.plan_type_display = self.providers.resolve_account_plan(
&account.provider_kind,
&mut account.plan_type,
cached_quota.as_ref(),
);
account
})
.buffered(8)
.collect()
.await;
Ok(page)
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
async fn create(
&self,
mut command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.create(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn update(
&self,
mut command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.update(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let result = self
.store
.delete(id, revision, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result).await?;
Ok(result)
}
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
Ok(self.probe.test(proxy).await)
}
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if record.revision != revision {
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
}
let result = self.probe.test(&record.proxy).await;
self.store
.record_test(id, revision, result, context)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
}
fn validate_name(value: &str) -> Result<String, AdminError> {
let value = value.trim();
if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) {
return Err(AdminError::invalid("代理名称需要 1 至 100 个字符"));
}
Ok(value.to_owned())
}
@@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync {
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -57,11 +58,13 @@ impl DefaultXaiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -97,6 +111,7 @@ impl XaiService for DefaultXaiService {
.accounts
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
@@ -104,6 +119,7 @@ impl XaiService for DefaultXaiService {
)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
drop(proxy_reservation);
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
for account_id in &result.credential_ids {
@@ -125,6 +141,7 @@ impl XaiService for DefaultXaiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"xAI credential",
@@ -97,7 +97,21 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
PendingAuthorizationMutation::from_storage_v1(Value::Object(
expected.to_storage_v1()
)),
Ok(expected),
Ok(expected.clone()),
);
let with_proxy = expected
.with_outbound_proxy(Some(
gateway_core::account::OutboundProxy::parse(
"http://user:secret@proxy.example:8080",
)
.unwrap(),
))
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
with_proxy.to_storage_v1()
)),
Ok(with_proxy)
);
}
}
@@ -1391,7 +1391,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
),
(
Some(" self_serve_business_prolite "),
Some("openai display: self_serve_business_prolite"),
Some("OpenaiDisplaySelfServeBusinessProlite"),
)
);
@@ -1409,16 +1409,27 @@ async fn accounts_list_should_return_complete_directory_semantics() {
#[tokio::test]
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
for (stored_plan, quota_plan, expected) in [
(None, Some("free"), Some("free")),
(Some(" "), Some("free"), Some("free")),
(Some("unknown"), Some("free"), Some("free")),
for (stored_plan, quota_plan, expected, expected_display) in [
(None, Some("free"), Some("free"), Some("OpenaiDisplayFree")),
(
Some(" "),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("unknown"),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("self_serve_business_prolite"),
Some("team"),
Some("self_serve_business_prolite"),
Some("OpenaiDisplaySelfServeBusinessProlite"),
),
(None, None, None),
(None, None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
@@ -1444,10 +1455,7 @@ async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_s
.expect("account list");
let account = page.items.first().expect("account item");
assert_eq!(account.account.plan_type.as_deref(), expected);
assert_eq!(
account.plan_type_display,
expected.map(|plan| format!("openai display: {plan}"))
);
assert_eq!(account.plan_type_display.as_deref(), expected_display);
for refresh in [false, true] {
let detail = services
.accounts()
@@ -5,6 +5,7 @@ mod backup;
mod client_keys;
mod observability;
mod openai;
mod proxies;
mod settings;
mod system;
mod xai;
@@ -82,6 +83,7 @@ pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
@@ -101,6 +103,7 @@ impl AdminHarness {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
@@ -179,6 +182,14 @@ impl AdminHarness {
self
}
pub(super) fn proxies(
mut self,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
) -> Self {
self.proxies = proxies;
self
}
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
self.system = system;
self
@@ -196,6 +207,7 @@ impl AdminHarness {
self.accounts,
self.account_runtime,
self.account_groups,
self.proxies,
),
self.auth,
self.client_keys,
@@ -205,7 +217,7 @@ impl AdminHarness {
),
self.providers,
Arc::new(NoopSnapshot),
self.probe,
(self.probe, Arc::new(proxies::TestProxies::default())),
Arc::new(NoopClientDistribution),
self.system,
)
@@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-openai"),
document: document(),
@@ -107,6 +108,7 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ
let error = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-pat-failure"),
document: document(),
@@ -132,6 +134,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-batch"),
document: document(),
@@ -176,6 +179,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-quota-failure"),
document: document(),
@@ -348,6 +352,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-error"),
document: document(),
@@ -0,0 +1,227 @@
use async_trait::async_trait;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreResult,
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
#[derive(Default)]
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
}
struct ImportGuard(super::accounts::EventLog);
impl gateway_admin::ports::proxy::ProxyImportGuard for ImportGuard {}
impl Drop for ImportGuard {
fn drop(&mut self) {
self.0.lock().unwrap().push("proxy.release");
}
}
#[async_trait]
impl ProxyStore for TestProxies {
async fn remove_account(
&self,
_: &str,
_: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn reserve_import(
&self,
id: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
let events = self
.events
.as_ref()
.ok_or_else(|| super::unavailable("proxy"))?;
events.lock().unwrap().push("proxy.reserve");
Ok(gateway_admin::ports::proxy::ProxyImportReservation {
binding: ImportProxyBinding {
id: id.to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
},
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
let items = self
.accounts
.clone()
.ok_or_else(|| super::unavailable("proxy"))?;
Ok(ProxyAccountPage {
total: items.len() as u64,
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn record_test(
&self,
_: &str,
_: Revision,
_: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: &OutboundProxy) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota};
use std::sync::Arc;
for (stored, cached, expected) in [
(None, Some("free"), Some("free")),
(Some("unknown"), Some("free"), Some("free")),
(Some("plus"), Some("free"), Some("plus")),
(None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
windows: vec![],
limit_reached: false,
provider_data: None,
});
let services = super::AdminHarness::new()
.provider(provider.clone())
.proxies(Arc::new(TestProxies {
accounts: Some(vec![ProxyAccountRef {
id: "acct_plan".to_owned(),
name: "套餐测试".to_owned(),
email: None,
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: stored.map(str::to_owned),
plan_type_display: None,
groups: vec![],
enabled: true,
}]),
..Default::default()
}))
.build()
.await;
let result = services
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: "proxy_plan".to_owned(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap();
assert_eq!(result.items[0].plan_type.as_deref(), expected);
assert_eq!(
result.items[0].plan_type_display.as_deref(),
match expected {
Some("free") => Some("OpenaiDisplayFree"),
Some("plus") => Some("OpenaiDisplayPlus"),
_ => None,
}
);
let requests = provider.quota_requests();
assert_eq!(requests.len(), usize::from(stored != Some("plus")));
assert!(requests.iter().all(|request| !request.refresh));
}
}
#[tokio::test]
async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() {
use super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
};
use gateway_admin::model::provider_credentials::ImportCredentials;
use gateway_admin::ports::provider::ProviderAdminErrorKind;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for failure in [None, Some("prepare"), Some("commit")] {
let events = events();
let provider = FakeProviderAdmin::new(kind, events.clone());
let store = FakeAccountStore::new(kind, events.clone());
if failure == Some("prepare") {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
if failure == Some("commit") {
store.fail_next_commit();
}
let services = super::AdminHarness::new()
.provider(provider)
.accounts(store)
.proxies(Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
let command = ImportCredentials {
outbound_proxy_id: Some("proxy_import".to_owned()),
settings: None,
context: context("reserved-import"),
document: document(),
};
let result = if kind == "openai" {
services.openai().import_document(command).await
} else {
services.xai().import_document(command).await
};
assert_eq!(result.is_err(), failure.is_some());
let events = recorded(&events);
let expected = if failure == Some("prepare") {
vec!["proxy.reserve", "provider.prepare_import", "proxy.release"]
} else {
vec![
"proxy.reserve",
"provider.prepare_import",
"store.commit_import",
"proxy.release",
]
};
assert_eq!(&events[..expected.len()], expected);
}
}
}
@@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-xai"),
document: document(),
@@ -61,6 +62,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-batch"),
document: document(),
@@ -96,6 +98,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-quota-failure"),
document: document(),
@@ -53,6 +53,7 @@ impl AccountImportSettingsRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub data: Value,
@@ -60,6 +61,9 @@ pub struct AccountImportRequest {
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(id) = &self.outbound_proxy_id {
require_wire_id(id, "outboundProxyId")?;
}
if let Some(settings) = &self.settings {
settings.validate()?;
}
@@ -82,6 +86,7 @@ impl AccountImportRequest {
Ok((
provider,
ImportCredentials {
outbound_proxy_id: self.outbound_proxy_id,
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
@@ -96,6 +101,7 @@ impl AccountImportRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub outbound_proxy_id: Option<String>,
pub provider: String,
pub name: String,
pub account_id: Option<String>,
@@ -129,7 +135,10 @@ impl StartAccountAuthorizationRequest {
context,
name: self.name,
reauthorization,
outbound_proxy: self.outbound_proxy_url.and_then(|value| value.0),
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
},
))
}
@@ -188,6 +197,7 @@ impl CompleteAccountAuthorizationRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub enabled: bool,
@@ -209,7 +219,10 @@ impl UpdateAccountRequest {
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
self.validate()?;
Ok(UpdateAccount {
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
account_id: self.account_id,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -17,6 +17,33 @@ impl<'de> Deserialize<'de> for AccountProxyUpdate {
}
}
pub(super) fn proxy_selection(
id: Option<String>,
url: Option<AccountProxyUpdate>,
) -> Result<Option<gateway_admin::model::proxies::AccountProxySelection>, WireValidationError> {
use gateway_admin::model::proxies::AccountProxySelection;
match (id, url) {
(Some(_), Some(_)) => Err(WireValidationError::new("outboundProxyId")),
(Some(id), None) if id.is_empty() => Ok(Some(AccountProxySelection::Direct)),
(Some(id), None) => {
if id.len() > 128
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(WireValidationError::new("outboundProxyId"));
}
Ok(Some(AccountProxySelection::Saved(id)))
}
(None, Some(value)) => Ok(Some(
value
.0
.map_or(AccountProxySelection::Direct, AccountProxySelection::Url),
)),
(None, None) => Ok(None),
}
}
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -34,6 +61,7 @@ pub struct ListQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
pub enabled: bool,
@@ -84,7 +112,7 @@ impl BatchUpdateAccountsRequest {
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -275,7 +275,6 @@ pub struct ClientKeyView {
weekly_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
weekly_resets_at: Option<DateTime<Utc>>,
unresolved_requests: u64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
@@ -327,7 +326,6 @@ impl From<ClientKeyRecord> for ClientKeyView {
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
unresolved_requests: record.budget.unresolved_requests,
created_at: record.created_at,
updated_at: record.updated_at,
last_used_at: record.last_used_at,
@@ -679,14 +677,6 @@ where
{
Router::new()
.route("/api/admin/client-keys", get(list_client_keys::<S>))
.route(
"/api/admin/client-keys/unresolved-charges",
get(unresolved_client_charges::<S>),
)
.route(
"/api/admin/client-keys/reconcile-charge",
post(reconcile_client_charge::<S>),
)
.route(
"/api/admin/client-keys/create",
post(create_client_key::<S>),
@@ -710,66 +700,6 @@ where
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ReconcileClientChargeRequest {
id: String,
request_id: String,
amount_usd: String,
reason: String,
}
async fn unresolved_client_charges<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = query.into_domain_id().map_err(map_wire_error)?;
let items = state.admin_services().client_keys().unresolved_charges(&id).await.map_err(map_service_error)?
.into_iter().map(|event| serde_json::json!({
"requestId": event.request_id, "startedAt": event.started_at.to_rfc3339(),
"completedAt": event.completed_at.map(|time| time.to_rfc3339()), "state": event.state,
})).collect::<Vec<_>>();
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"items": items})),
))
}
async fn reconcile_client_charge<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ReconcileClientChargeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let key_id = client_key_id(payload.id, "id").map_err(map_wire_error)?;
let amount_usd = payload
.amount_usd
.parse::<Decimal>()
.map_err(|_| map_wire_error(WireValidationError::new("amountUsd")))?;
let command = gateway_admin::model::client_keys::ReconcileClientCharge {
key_id,
request_id: payload.request_id,
amount_usd,
reason: payload.reason,
};
state
.admin_services()
.client_keys()
.reconcile_charge(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"reconciled": true})),
))
}
async fn list_client_keys<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -16,6 +16,7 @@ pub mod client_keys;
mod extract;
pub mod observability;
pub mod presenter;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod wire;
@@ -34,6 +35,7 @@ where
{
Router::new()
.merge(account_groups::router::<S>())
.merge(proxies::router::<S>())
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(backups::router::<S>())
@@ -0,0 +1,452 @@
use axum::{
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use gateway_admin::model::{
PageSize, Revision,
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
ProxyTestResult, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ListQuery {
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AccountsQuery {
proxy_id: String,
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RemoveAccountRequest {
proxy_id: String,
account_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
name: String,
proxy_url: AccountProxyUpdate,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct IdRequest {
id: String,
revision: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProbeRequest {
proxy_url: AccountProxyUpdate,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
message: String,
}
impl From<ProxyTestResult> for ProxyTestView {
fn from(result: ProxyTestResult) -> Self {
Self {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
message: result.message,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
id: String,
name: String,
email: Option<String>,
provider: String,
authentication_kind: String,
plan_type: Option<String>,
plan_type_display: Option<String>,
groups: Vec<AccountGroupRefView>,
enabled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
id: String,
name: String,
endpoint: String,
has_authentication: bool,
revision: u64,
account_count: u64,
last_test_at: Option<String>,
last_test: Option<ProxyTestView>,
created_at: String,
updated_at: String,
}
impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
let endpoint = record.proxy.endpoint();
Self {
id: record.id,
name: record.name,
has_authentication: record.proxy.expose_url() != endpoint,
endpoint,
revision: record.revision.get(),
account_count: record.account_count,
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
last_test: record.last_test.map(Into::into),
created_at: record.created_at.to_rfc3339(),
updated_at: record.updated_at.to_rfc3339(),
}
}
}
#[derive(Serialize)]
struct ProxyPageView {
items: Vec<ProxyView>,
page: PageMeta,
}
#[derive(Serialize)]
struct ProxyAccountPageView {
items: Vec<ProxyAccountView>,
page: PageMeta,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MutationView {
record: ProxyView,
config_revision: u64,
}
impl From<ProxyMutation> for MutationView {
fn from(value: ProxyMutation) -> Self {
Self {
record: value.record.into(),
config_revision: value.config_revision.get(),
}
}
}
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/proxies", get(list::<S>))
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
.route(
"/api/admin/proxies/accounts/remove",
post(remove_account::<S>),
)
.route("/api/admin/proxies/create", post(create::<S>))
.route("/api/admin/proxies/update", post(update::<S>))
.route("/api/admin/proxies/delete", post(delete::<S>))
.route("/api/admin/proxies/test", post(test::<S>))
.route("/api/admin/proxies/probe", post(probe::<S>))
}
fn revision(value: u64) -> Result<Revision, AdminError> {
if value > i64::MAX as u64 {
return Err(AdminError::bad_request("代理版本不合法"));
}
Revision::new(value).map_err(|_| AdminError::bad_request("代理版本不合法"))
}
fn map_error(error: gateway_admin::model::AdminError) -> AdminError {
super::wire::map_admin_service_error(error)
}
async fn list<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list(ProxyListQuery {
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyPageView {
items: result.items.into_iter().map(Into::into).collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn list_accounts<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: query.proxy_id,
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyAccountPageView {
items: result
.items
.into_iter()
.map(|account| ProxyAccountView {
id: account.id,
name: account.name,
email: account.email,
provider: account.provider_kind,
authentication_kind: account.authentication_kind,
plan_type: account.plan_type,
plan_type_display: account.plan_type_display,
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
enabled: account.enabled,
})
.collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.create(
NewProxy {
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn remove_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RemoveAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let revision = state
.admin_services()
.proxies()
.remove_account(
&request.proxy_id,
&request.account_id,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})),
))
}
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.map(|value| {
value
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))
})
.transpose()?;
let result = state
.admin_services()
.proxies()
.update(
UpdateProxy {
id: request.id,
revision: revision(request.revision)?,
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.delete(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": result.get()})),
))
}
async fn probe<S>(
_: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<ProbeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.probe(&proxy)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyTestView::from(result)),
))
}
async fn test<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.test(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from(result)),
))
}
@@ -50,44 +50,6 @@ fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
);
}
#[tokio::test]
async fn budget_reconciliation_routes_require_an_admin_session() {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
let router = client_keys::router::<AdminTestState>().with_state(fixture.state());
for (method, path, body) in [
(
"GET",
"/api/admin/client-keys/unresolved-charges?id=key_1",
"",
),
(
"POST",
"/api/admin/client-keys/reconcile-charge",
r#"{"id":"key_1","requestId":"req_charge","amountUsd":"0","reason":"verified"}"#,
),
] {
let response = router
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("x-request-id", "req_auth_budget")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}
#[test]
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
@@ -320,7 +282,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
assert_eq!(list["items"][0]["dailyLimitUsd"], "0");
assert_eq!(list["items"][0]["weeklyLimitUsd"], "0");
assert_eq!(list["items"][0]["dailyUsedUsd"], "0");
assert_eq!(list["items"][0]["unresolvedRequests"], 0);
assert!(list["items"][0].get("unresolvedRequests").is_none());
assert_eq!(
DateTime::parse_from_rfc3339(
list["items"][0]["lastUsedAt"]
@@ -82,6 +82,7 @@ mod auth;
mod client_keys;
mod errors;
mod observability;
mod proxies;
mod settings;
mod system;
mod wire;
@@ -124,7 +125,12 @@ impl AdminTestFixture {
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
});
let stores = AdminStorePorts::new(
AdminAccountStorePorts::new(unused.clone(), unused.clone(), account_groups.clone()),
AdminAccountStorePorts::new(
unused.clone(),
unused.clone(),
account_groups.clone(),
Arc::new(proxies::MemoryProxies::default()),
),
auth.clone(),
client_keys.clone(),
unused,
@@ -144,7 +150,7 @@ impl AdminTestFixture {
stores,
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
Arc::new(StaticClientDistribution),
system,
)
@@ -0,0 +1,520 @@
use std::sync::Mutex;
use async_trait::async_trait;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
#[derive(Default)]
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
fn missing() -> AdminStoreError {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "missing proxy")
}
#[async_trait]
impl ProxyStore for MemoryProxies {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy account",
"changed binding",
));
}
Ok(Revision::new(4).unwrap())
}
async fn reserve_import(
&self,
_: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
Err(missing())
}
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect();
Ok(ProxyPage {
total: u64::try_from(items.len()).unwrap(),
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
self.0.lock().unwrap().clone().ok_or_else(missing)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if !self
.0
.lock()
.unwrap()
.as_ref()
.is_some_and(|record| record.id == query.proxy_id)
{
return Err(missing());
}
Ok(ProxyAccountPage {
items: vec![ProxyAccountRef {
id: "acct_linked".to_owned(),
name: "工作账号".to_owned(),
email: Some("work@example.invalid".to_owned()),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: Some("plus".to_owned()),
plan_type_display: None,
groups: vec![],
enabled: true,
}],
total: 21,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn create(
&self,
command: NewProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let record = ProxyRecord {
id: "proxy_test".to_owned(),
name: command.name,
proxy: command.proxy,
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test: None,
last_test_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
*self.0.lock().unwrap() = Some(record.clone());
Ok(ProxyMutation {
config_revision: record.revision,
record,
})
}
async fn update(
&self,
command: UpdateProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.name = command.name;
if let Some(proxy) = command.proxy {
record.proxy = proxy;
}
record.revision = Revision::new(record.revision.get() + 1).unwrap();
Ok(ProxyMutation {
config_revision: record.revision,
record: record.clone(),
})
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
self.0.lock().unwrap().take().ok_or_else(missing)?;
Ok(Revision::new(3).unwrap())
}
async fn record_test(
&self,
_: &str,
_: Revision,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
Ok(record.clone())
}
}
pub(super) struct SuccessfulProbe;
#[async_trait]
impl ProxyProbe for SuccessfulProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
ProxyTestResult {
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
message: "Connected".to_owned(),
}
}
}
async fn request(
fixture: &AdminTestFixture,
path: &str,
body: Option<Value>,
authenticated: bool,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.header("x-request-id", "req_proxy_tests")
.uri(path)
.method(if body.is_some() { "POST" } else { "GET" });
if authenticated {
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
}
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
let response = gateway_api::admin::proxies::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
builder
.header(header::CONTENT_TYPE, "application/json")
.body(body)
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(!text.contains("private-password"));
assert!(!text.contains("test-user"));
(status, serde_json::from_slice(&body).unwrap())
}
#[tokio::test]
async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_credentials() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
"name": " Office ", "proxyUrl": "http://test-user:private-password@proxy.example:8080"
})),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["name"], "Office");
assert_eq!(created["data"]["record"]["hasAuthentication"], true);
assert_eq!(
created["data"]["record"]["endpoint"],
"http://proxy.example:8080/"
);
assert!(created["data"]["record"].get("proxyUrl").is_none());
assert_eq!(created["data"]["record"]["accountCount"], 0);
assert!(created["data"]["record"].get("accounts").is_none());
let (status, linked) = request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work",
None,
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
linked["data"]["page"],
json!({"page":2,"pageSize":20,"total":21,"totalPages":2})
);
assert_eq!(
linked["data"]["items"],
json!([{
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
}])
);
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2");
let (_, listed) = request(
&fixture,
"/api/admin/proxies?page=1&pageSize=20",
None,
true,
)
.await;
assert_eq!(listed["data"]["items"][0], tested["data"]);
let (status, _) = request(
&fixture,
"/api/admin/proxies/update",
Some(json!({"id": "proxy_test", "revision": 1, "name": "Renamed"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/delete",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
}
#[tokio::test]
async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let draft = json!({
"proxyUrl": "http://test-user:private-password@proxy.example:8080"
});
let (status, probed) = request(
&fixture,
"/api/admin/proxies/probe",
Some(draft.clone()),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
probed["data"],
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
let (_, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
true,
)
.await;
let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
}
#[tokio::test]
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/probe", Some(body), true)
.await
.0,
expected
);
}
}
#[tokio::test]
async fn proxy_routes_require_auth_and_reject_invalid_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
assert_eq!(
request(&fixture, "/api/admin/proxies", None, false).await.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test",
None,
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
for query in [
"",
"proxyId=",
"proxyId=proxy_test&page=0",
"proxyId=proxy_test&pageSize=0",
"proxyId=proxy_test&pageSize=201",
"proxyId=proxy_test&search=%00",
"proxyId=proxy_test&unknown=1",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/accounts?{query}"),
None,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=missing",
None,
true
)
.await
.0,
StatusCode::NOT_FOUND
);
for action in [
"create",
"update",
"delete",
"test",
"probe",
"accounts/remove",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/{action}"),
Some(json!({})),
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
}
for query in ["page=0", "pageSize=0", "pageSize=201", "unknown=1"] {
assert_eq!(
request(&fixture, &format!("/api/admin/proxies?{query}"), None, true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
for body in [
json!({"name":" ","proxyUrl":"http://proxy.example:8080"}),
json!({"name":"Invalid","proxyUrl":""}),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/create", Some(body), true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
let (status, _) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})),
true,
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn proxy_account_removal_validates_binding_and_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(
json!({"proxyId":"", "accountId":"acct_linked"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":""}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"invalid"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(body),
true
)
.await
.0,
expected
);
}
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"], json!({"configRevision":4}));
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试");
}
@@ -50,6 +50,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/observability/routes.rs",
"src/admin/observability/wire.rs",
"src/admin/presenter.rs",
"src/admin/proxies.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
@@ -96,6 +97,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/observability/mod.rs",
"tests/admin/observability/query.rs",
"tests/admin/observability/response.rs",
"tests/admin/proxies.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
@@ -1,4 +1,4 @@
//! Durable per-key USD budgets, independent of best-effort request observations.
//! 按 Key 持久化 USD 限额与已取得费用,不依赖尽力写入的请求观测。
use std::time::SystemTime;
@@ -28,21 +28,14 @@ pub struct ClientBudgetStatus {
pub weekly_used_usd: Decimal,
pub daily_resets_at: Option<SystemTime>,
pub weekly_resets_at: Option<SystemTime>,
pub unresolved_requests: u64,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetAdmission {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
pub deadline_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetCharge {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
/// None means the request may have incurred an unknown charge, never zero.
pub amount_usd: Option<Decimal>,
/// 已取得的 USD 费用,包含重试;缺少费用的尝试按零累计。
pub amount_usd: Decimal,
pub completed_at: SystemTime,
}
@@ -51,9 +44,9 @@ pub struct ClientBudgetCharge {
pub struct ClientBudgetError;
pub trait ClientBudgetPort: Send + Sync {
/// Persist a pending charge before any upstream work; read the current policy atomically.
fn admit(&self, request: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>>;
/// 原子检查当前限额与已用金额,不创建预扣费或待结算记录。
fn admit(&self, key_id: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>>;
/// Idempotent by gateway request ID. Failed writes leave the durable pending charge intact.
/// 按网关请求 ID 幂等累计已取得费用;写入失败由 Store 重试。
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>>;
}
@@ -167,8 +167,7 @@ where
request_id,
client_api_key_ref,
observation: ResponseObservation::new(timing_started_at),
budget_prior_attempts_usd: Some(Decimal::ZERO),
budget_attempt_send_state: UpstreamSendState::NotSent,
budget_prior_attempts_usd: Decimal::ZERO,
budget_attempt_already_counted: false,
trace,
deadline,
@@ -258,8 +257,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
request_id: ModelRequestId,
client_api_key_ref: crate::policy::ClientApiKeyId,
observation: ResponseObservation,
budget_prior_attempts_usd: Option<Decimal>,
budget_attempt_send_state: UpstreamSendState,
budget_prior_attempts_usd: Decimal,
budget_attempt_already_counted: bool,
trace: TraceContext,
deadline: SystemTime,
@@ -486,30 +484,31 @@ where
#[must_use]
pub fn budget_charge(&self) -> super::budget::ClientBudgetCharge {
// 超出数据库可表示范围时保留最大金额,避免溢出后误记为零。
let amount_usd = self
.budget_prior_attempts_usd
.zip(self.budget_attempt_usd())
.and_then(|(prior, current)| prior.checked_add(current));
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
super::budget::ClientBudgetCharge {
key_id: self.client_api_key_ref.clone(),
request_id: self.request_id.clone(),
amount_usd,
completed_at: self.finalized_at.unwrap_or_else(SystemTime::now),
}
}
fn budget_attempt_usd(&self) -> Option<Decimal> {
fn budget_attempt_usd(&self) -> Decimal {
if self.budget_attempt_already_counted {
return Some(Decimal::ZERO);
return Decimal::ZERO;
}
// Key 只累计已取得的 USD 费用;缺少费用的请求按零结算。
// 发送状态仍用于判断重放是否安全并保留在诊断中,不能据此推断存在欠费。
self.observation
.cost
.total()
.filter(|money| money.currency().as_str() == "USD")
.map(crate::metering::Money::amount)
.or_else(|| {
(self.budget_attempt_send_state == UpstreamSendState::NotSent)
.then_some(Decimal::ZERO)
})
.unwrap_or(Decimal::ZERO)
}
/// 返回截至当前已完成的实际上游调用结果。
@@ -786,8 +785,6 @@ where
}),
);
let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone());
// Cancellation while a provider is opening cannot prove the request was never sent.
self.budget_attempt_send_state = UpstreamSendState::Ambiguous;
let stream = match poll_provider(
provider,
provider_request,
@@ -811,7 +808,6 @@ where
ProviderBoundary::Result(result) => match *result {
Ok(stream) => stream,
Err(error) => {
self.budget_attempt_send_state = error.send_state();
record_trace_error(&attempt_trace, &error);
if self.prepare_unavailable_native_continuation_replay(&error) {
return Ok(Some(PullOutcome::AttemptDiscarded));
@@ -1068,7 +1064,6 @@ where
.await;
}
current.send_observed = true;
self.budget_attempt_send_state = UpstreamSendState::Sent;
self.send_state_watermark = UpstreamSendState::Sent;
}
}
@@ -1108,7 +1103,6 @@ where
error.send_state()
};
let send_state = self.raise_send_watermark(attempt_send_state);
self.budget_attempt_send_state = attempt_send_state;
if self.request_persisted {
best_effort_store_write(
"mark_send_state",
@@ -1365,12 +1359,11 @@ where
}
fn reset_uncommitted_observations(&mut self) {
// Response facts are attempt-local; the key must also pay for discarded attempts.
// 响应观测按尝试隔离;被丢弃尝试中已取得的费用仍计入 Key。
self.budget_prior_attempts_usd = self
.budget_prior_attempts_usd
.zip(self.budget_attempt_usd())
.and_then(|(prior, current)| prior.checked_add(current));
self.budget_attempt_send_state = UpstreamSendState::NotSent;
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
self.budget_attempt_already_counted = false;
self.observation.reset_for_attempt();
self.upstream_complete = false;
@@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::engine::admission::{
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
};
use crate::engine::budget::{ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetPort};
use crate::engine::budget::{ClientBudgetCharge, ClientBudgetPort};
use crate::engine::continuation::{
ContinuationBinding, NativeContinuationPin, NativeContinuationPort,
NativeContinuationStoreErrorKind, PreviousResponseId,
@@ -523,13 +523,7 @@ impl DefaultExecutionService {
model_request_id: request_id.clone(),
};
if let Some(budget) = &self.budget
&& let Err(error) = budget
.admit(ClientBudgetAdmission {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
deadline_at,
})
.await
&& let Err(error) = budget.admit(client.policy.key_id().clone()).await
{
admission.release().await;
return Err(error);
@@ -586,8 +580,9 @@ impl DefaultExecutionService {
settle_budget(
budget.as_ref(),
ClientBudgetCharge {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
amount_usd: Some(crate::metering::Decimal::ZERO),
amount_usd: crate::metering::Decimal::ZERO,
completed_at: SystemTime::now(),
},
)
@@ -966,7 +961,7 @@ struct AdmissionLease {
async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge) {
if let Err(error) = port.settle(charge).await {
tracing::error!(%error, "Client budget settlement pending; durable admission requires reconciliation");
tracing::error!(%error, "Client budget settlement failed; storage will retry on the next request");
}
}
@@ -14,6 +14,8 @@ pub struct Decimal(u128);
impl Decimal {
pub const ZERO: Self = Self(0);
/// 数据库可表示的最大非负金额。
pub const MAX: Self = Self(MAX_SCALED_DECIMAL);
/// 从按十位小数缩放的整数创建。
///
@@ -2159,18 +2159,23 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
assert_eq!(state.finalizations[0].cost_source, CostSource::Unavailable);
assert_eq!(state.finalizations[0].cost_ticks, None);
assert_eq!(
session.budget_charge().amount_usd,
None,
"unknown final usage cannot erase a charged retry"
session.budget_charge().amount_usd.scaled(),
999,
"missing final usage cannot erase a charged retry"
);
}
#[test]
fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
fn key_budget_accumulates_only_observed_retry_costs() {
for (first_cost, first_send, expected) in [
(Some(999), UpstreamSendState::Sent, Some(1122)),
(None, UpstreamSendState::Sent, None),
(None, UpstreamSendState::NotSent, Some(123)),
(Some(999), UpstreamSendState::Sent, 1122),
(
Some(gateway_core::metering::Decimal::MAX.scaled()),
UpstreamSendState::Sent,
gateway_core::metering::Decimal::MAX.scaled(),
),
(None, UpstreamSendState::Sent, 123),
(None, UpstreamSendState::NotSent, 123),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
@@ -2225,7 +2230,7 @@ fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
block_on(session.collect_uncommitted()).unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
let charge = session.budget_charge();
assert_eq!(charge.amount_usd.map(|amount| amount.scaled()), expected);
assert_eq!(charge.amount_usd.scaled(), expected);
assert_eq!(
session.budget_charge().completed_at,
charge.completed_at,
@@ -2234,6 +2239,67 @@ fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
}
}
#[test]
fn key_budget_settles_post_send_failures_from_observed_cost_without_replaying() {
for send_state in [UpstreamSendState::Sent, UpstreamSendState::Ambiguous] {
for cost in [None, Some(123)] {
for committed in [false, true] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut items = vec![Ok(GatewayEvent::Started(ResponseMeta::new(
"response-interrupted",
"gpt-5",
)))];
if let Some(cost) = cost {
items.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
items.push(Err(ProviderError::new(
ProviderErrorKind::Transport,
send_state,
)));
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items,
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
if committed {
block_on(session.next_event()).unwrap().unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
loop {
match block_on(session.next_event()) {
Ok(Some(_)) => {}
Err(EngineError::Provider(_)) => break,
other => panic!("expected terminal transport error, got {other:?}"),
}
}
} else {
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
}
assert!(session.is_finalized());
assert_eq!(
session.budget_charge().amount_usd.scaled(),
cost.unwrap_or(0),
"send={send_state:?}, committed={committed}, cost={cost:?}"
);
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
}
}
}
}
#[test]
fn pre_commit_failure_excludes_account_and_retries_same_target() {
let operation = generate_operation();
@@ -2812,11 +2878,8 @@ fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire()
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
assert_eq!(
session
.budget_charge()
.amount_usd
.map(|amount| amount.scaled()),
Some(500),
session.budget_charge().amount_usd.scaled(),
500,
"restoring a discarded failure must not charge its usage twice"
);
let state = store.state.lock().expect("store lock");
@@ -2956,6 +3019,7 @@ fn ambiguous_send_state_stops_retry() {
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.send_state, UpstreamSendState::Ambiguous);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
assert_eq!(finalization.attempt_count, 1);
assert!(!finalization.committed);
assert!(finalization.latency_ms.is_some());
@@ -3430,6 +3494,7 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
assert_eq!(state.commits, 0);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Cancelled);
assert!(!state.finalizations[0].committed);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
}
#[test]
@@ -7,9 +7,7 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::budget::{
ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetError, ClientBudgetPort,
};
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
#[derive(Default)]
@@ -51,7 +49,7 @@ struct Budget {
}
impl ClientBudgetPort for Budget {
fn admit(&self, _: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>> {
fn admit(&self, _: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async {
assert!(self.active.load(Ordering::SeqCst));
if self.reject {
@@ -230,7 +228,7 @@ fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all
assert!(
charges
.iter()
.all(|charge| charge.amount_usd == Some(gateway_core::metering::Decimal::ZERO))
.all(|charge| charge.amount_usd == gateway_core::metering::Decimal::ZERO)
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
scraper = "0.27.0"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
+12
View File
@@ -3,6 +3,7 @@
pub mod client_distribution;
pub mod config;
mod logging;
pub mod proxy_probe;
pub mod serve;
pub mod system_update;
pub mod workers;
@@ -81,6 +82,17 @@ impl HostBundle {
self.client_distribution.clone()
}
#[must_use]
pub fn proxy_probe<E>(
&self,
build_client: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E>
+ Send
+ Sync
+ 'static,
) -> Arc<dyn gateway_admin::ports::proxy::ProxyProbe> {
Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client))
}
#[must_use]
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
self.connections.clone()
@@ -0,0 +1,105 @@
//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试。
use std::{
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use async_trait::async_trait;
use gateway_admin::{model::proxies::ProxyTestResult, ports::proxy::ProxyProbe};
use gateway_core::account::OutboundProxy;
use serde::Deserialize;
pub struct HttpProxyProbe {
endpoint: String,
build_client: Arc<ProxyClientBuilder>,
}
type ProxyClientBuilder =
dyn Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, &'static str> + Send + Sync;
impl Default for HttpProxyProbe {
fn default() -> Self {
Self::new("https://api.ipify.org?format=json")
}
}
impl HttpProxyProbe {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")),
}
}
/// 由组合根注入与 Provider 请求一致的证书信任策略。
#[must_use]
pub fn with_client_builder<E>(
mut self,
build: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E> + Send + Sync + 'static,
) -> Self {
self.build_client = Arc::new(move |builder| {
build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置")
});
self
}
async fn exit_ip(&self, proxy: &OutboundProxy) -> Result<IpAddr, &'static str> {
let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?;
let builder = reqwest::Client::builder()
.no_proxy()
.proxy(proxy)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(reqwest::redirect::Policy::none());
let client = (self.build_client)(builder)?;
let mut response = client.get(&self.endpoint).send().await.map_err(|error| {
if error.is_timeout() {
"代理连接超时"
} else {
"代理连接失败,请检查地址、认证和网络"
}
})?;
if !response.status().is_success() {
return Err(
if response.status() == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
"代理认证失败"
} else {
"出口检测服务返回错误状态"
},
);
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "出口检测响应读取失败")?
{
if body.len() + chunk.len() > 1024 {
return Err("出口检测响应过大");
}
body.extend_from_slice(&chunk);
}
#[derive(Deserialize)]
struct Response {
ip: IpAddr,
}
serde_json::from_slice::<Response>(&body)
.map(|response| response.ip)
.map_err(|_| "出口检测响应不合法")
}
}
#[async_trait]
impl ProxyProbe for HttpProxyProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
let started = Instant::now();
let result = tokio::time::timeout(Duration::from_secs(15), self.exit_ip(proxy)).await;
let result = result.unwrap_or(Err("代理连接超时"));
ProxyTestResult {
success: result.is_ok(),
latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
exit_ip: result.as_ref().ok().copied(),
message: result.map_or_else(str::to_owned, |_| "连接成功".to_owned()),
}
}
}
@@ -1,6 +1,7 @@
mod client_distribution;
mod config;
mod logging;
mod proxy_probe;
mod serve;
mod system_update;
mod workers;
@@ -0,0 +1,78 @@
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header},
};
#[tokio::test]
async fn proxy_probe_sends_authentication_through_explicit_proxy() {
let proxy_server = MockServer::start().await;
Mock::given(header("proxy-authorization", "Basic dXNlcjpwYXNzd29yZA=="))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"})))
.expect(1)
.mount(&proxy_server)
.await;
let proxy =
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())).unwrap();
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&proxy)
.await;
assert!(result.success);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
}
#[tokio::test]
async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_responses() {
for response in [
ResponseTemplate::new(407),
ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1/"),
ResponseTemplate::new(200).set_body_json(json!({"ip": "not-an-ip"})),
ResponseTemplate::new(200).set_body_string("a".repeat(1025)),
] {
let proxy_server = MockServer::start().await;
Mock::given(any())
.respond_with(response)
.expect(1)
.mount(&proxy_server)
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap())
.await;
assert!(!result.success);
assert!(result.exit_ip.is_none());
}
}
#[tokio::test]
async fn unavailable_proxy_never_falls_back_to_direct_connection() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip":"203.0.113.8"})))
.expect(0)
.mount(&target)
.await;
let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap();
drop(unused);
let result = HttpProxyProbe::new(target.uri()).test(&proxy).await;
assert!(!result.success);
}
#[tokio::test]
async fn invalid_certificate_configuration_should_not_fall_back_or_expose_details() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&target)
.await;
let result = HttpProxyProbe::new(target.uri())
.with_client_builder(|_| Err("private-certificate-path"))
.test(&OutboundProxy::parse(&target.uri()).unwrap())
.await;
assert!(!result.success);
assert!(!result.message.contains("private-certificate-path"));
}
@@ -106,6 +106,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
)),
admin_account_runtime,
Arc::new(postgres::PgAccountGroupRepository::new(pool.clone())),
Arc::new(postgres::PgProxyRepository::new(pool.clone())),
),
Arc::new(AdminAuthStoreAdapter {
security: postgres::PgAdminSecurityAuditRepository::new(pool.clone()),
@@ -1,4 +1,4 @@
//! Durable admission and idempotent USD settlement, serialized per client key.
//! 按 Key 串行检查限额,并幂等累计已取得的 USD 费用。
use std::{collections::BTreeMap, sync::Mutex, time::Duration};
@@ -6,11 +6,12 @@ use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::{
engine::budget::{
ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetError, ClientBudgetLimits,
ClientBudgetPort, ClientBudgetStatus,
ClientBudgetCharge, ClientBudgetError, ClientBudgetLimits, ClientBudgetPort,
ClientBudgetStatus,
},
error::{GatewayError, GatewayErrorKind},
metering::Decimal,
policy::ClientApiKeyId,
};
use sqlx::{PgPool, Postgres, Row, Transaction};
@@ -30,42 +31,25 @@ impl PgClientBudgetStore {
}
}
async fn admit_inner(&self, request: ClientBudgetAdmission) -> Result<(), GatewayError> {
// Preserve exact charges across a transient outage. On process loss the durable
// pending event still blocks a limited key after its deadline, requiring reconciliation.
async fn admit_inner(&self, key_id: ClientApiKeyId) -> Result<(), GatewayError> {
// 短暂存储故障后按原金额重试;进程退出丢失的费用不转成人工核账或阻断 Key。
let retries = self
.retry
.lock()
.map_err(|_| unavailable())?
.values()
.filter(|charge| charge.key_id == key_id)
.cloned()
.collect::<Vec<_>>();
if !retries.is_empty() {
let ids = retries
.iter()
.map(|charge| charge.request_id.as_str())
.collect::<Vec<_>>();
let owned = sqlx::query_scalar::<_, String>(
"select request_id from client_key_charge_events
where client_api_key_id = $1 and request_id = any($2)",
)
.bind(request.key_id.as_str())
.bind(ids)
.fetch_all(&self.pool)
.await
.map_err(|_| unavailable())?;
for charge in retries {
if owned.iter().any(|id| id == charge.request_id.as_str()) {
self.settle(charge).await.map_err(|_| unavailable())?;
}
}
for charge in retries {
self.settle(charge).await.map_err(|_| unavailable())?;
}
let mut tx = self.pool.begin().await.map_err(|_| unavailable())?;
let row = sqlx::query(
"select daily_limit_usd::text, weekly_limit_usd::text, enabled
from client_api_keys where id = $1 for update",
)
.bind(request.key_id.as_str())
.bind(key_id.as_str())
.fetch_optional(&mut *tx)
.await
.map_err(|_| unavailable())?
@@ -92,25 +76,15 @@ impl PgClientBudgetStore {
.map_err(|_| unavailable())?,
};
let now = Utc::now();
advance_windows(&mut tx, request.key_id.as_str(), now)
advance_windows(&mut tx, key_id.as_str(), now)
.await
.map_err(|_| unavailable())?;
if limits.is_limited() {
let unresolved = sqlx::query_scalar::<_, bool>("select exists(select 1 from client_key_charge_events
where client_api_key_id = $1 and (state = 'unknown' or (state = 'pending' and deadline_at <= $2)))")
.bind(request.key_id.as_str()).bind(now).fetch_one(&mut *tx).await.map_err(|_| unavailable())?;
if unresolved {
return Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"client API key has unresolved usage; administrator reconciliation is required",
)
.with_client_code("key_budget_unresolved"));
}
let window = sqlx::query(
"select daily_used_usd::text, weekly_used_usd::text, daily_end, weekly_end
from client_key_budget_windows where client_api_key_id = $1",
)
.bind(request.key_id.as_str())
.bind(key_id.as_str())
.fetch_one(&mut *tx)
.await
.map_err(|_| unavailable())?;
@@ -145,32 +119,20 @@ impl PgClientBudgetStore {
.with_retry_after(retry));
}
}
sqlx::query(
"insert into client_key_charge_events (request_id, client_api_key_id, deadline_at)
values ($1, $2, $3)",
)
.bind(request.request_id.as_str())
.bind(request.key_id.as_str())
.bind(DateTime::<Utc>::from(request.deadline_at))
.execute(&mut *tx)
.await
.map_err(|_| unavailable())?;
tx.commit().await.map_err(|_| unavailable())
}
async fn settle_inner(&self, charge: &ClientBudgetCharge) -> Result<(), ClientBudgetError> {
let mut tx = self.pool.begin().await.map_err(|_| ClientBudgetError)?;
// Match admission's key -> window -> event lock order, including concurrent settlements.
// 与准入统一先锁 Key,再写窗口和费用,串行化同一 Key 的并发结算。
let key = sqlx::query_scalar::<_, String>(
"select k.id from client_api_keys k
join client_key_charge_events e on e.client_api_key_id = k.id
where e.request_id = $1 for update of k",
"select id from client_api_keys where id = $1 for update",
)
.bind(charge.request_id.as_str())
.bind(charge.key_id.as_str())
.fetch_optional(&mut *tx)
.await
.map_err(|_| ClientBudgetError)?;
let Some(key) = key else { return Ok(()) }; // Deleting a key also deletes its ledger.
let Some(key) = key else { return Ok(()) }; // 删除 Key 时也会删除其费用记录。
settle_in_transaction(&mut tx, &key, charge)
.await
.map_err(|_| ClientBudgetError)?;
@@ -184,38 +146,33 @@ async fn settle_in_transaction(
charge: &ClientBudgetCharge,
) -> Result<(), sqlx::Error> {
advance_windows(tx, key, Utc::now()).await?;
// 仅在请求结束时写入费用;请求 ID 冲突时不重复累计。
let changed = sqlx::query(
"update client_key_charge_events
set state = $2, amount_usd = $3::text::numeric, completed_at = $4
where request_id = $1 and state <> 'settled'",
"insert into client_key_charge_events (request_id, client_api_key_id, amount_usd, completed_at)
values ($1, $2, $3::text::numeric, $4)
on conflict (request_id) do nothing",
)
.bind(charge.request_id.as_str())
.bind(if charge.amount_usd.is_some() {
"settled"
} else {
"unknown"
})
.bind(charge.amount_usd.map(|amount| amount.canonical()))
.bind(key)
.bind(charge.amount_usd.canonical())
.bind(DateTime::<Utc>::from(charge.completed_at))
.execute(&mut **tx)
.await?
.rows_affected();
if changed == 1
&& let Some(amount) = charge.amount_usd
{
if changed == 1 {
sqlx::query("update client_key_budget_windows set
daily_used_usd = daily_used_usd + case when $3 >= daily_start and $3 < daily_end then $2::text::numeric else 0 end,
weekly_used_usd = weekly_used_usd + case when $3 >= weekly_start and $3 < weekly_end then $2::text::numeric else 0 end
where client_api_key_id = $1")
.bind(key).bind(amount.canonical()).bind(DateTime::<Utc>::from(charge.completed_at))
.bind(key).bind(charge.amount_usd.canonical()).bind(DateTime::<Utc>::from(charge.completed_at))
.execute(&mut **tx).await?;
}
Ok(())
}
impl ClientBudgetPort for PgClientBudgetStore {
fn admit(&self, request: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async move { self.admit_inner(request).await })
fn admit(&self, key_id: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async move { self.admit_inner(key_id).await })
}
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>> {
@@ -263,16 +220,19 @@ pub(super) async fn load_client_key_budgets(
.iter()
.map(|record| record.id.as_str())
.collect::<Vec<_>>();
let rows = sqlx::query("select k.id, k.daily_limit_usd::text, k.weekly_limit_usd::text,
let rows = sqlx::query(
"select k.id, k.daily_limit_usd::text, k.weekly_limit_usd::text,
(case when w.daily_end > now() then w.daily_used_usd else 0 end)::text as daily_used,
(case when w.weekly_end > now() then w.weekly_used_usd else 0 end)::text as weekly_used,
case when w.daily_end > now() then w.daily_end end as daily_end,
case when w.weekly_end > now() then w.weekly_end end as weekly_end,
(select count(*) from client_key_charge_events e where e.client_api_key_id = k.id
and (e.state = 'unknown' or (e.state = 'pending' and e.deadline_at <= now()))) as unresolved
case when w.weekly_end > now() then w.weekly_end end as weekly_end
from client_api_keys k left join client_key_budget_windows w on w.client_api_key_id = k.id
where k.id = any($1)")
.bind(ids).fetch_all(pool).await.map_err(|_| postgres_unavailable("load client budgets"))?;
where k.id = any($1)",
)
.bind(ids)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load client budgets"))?;
let mut budgets = BTreeMap::new();
for row in rows {
let parse = |field| -> StoreResult<Decimal> {
@@ -295,7 +255,6 @@ pub(super) async fn load_client_key_budgets(
weekly_resets_at: row
.get::<Option<DateTime<Utc>>, _>("weekly_end")
.map(Into::into),
unresolved_requests: row.get::<i64, _>("unresolved").unsigned_abs(),
},
);
}
@@ -314,99 +273,3 @@ fn unavailable() -> GatewayError {
)
.with_client_code("key_budget_unavailable")
}
pub(super) async fn unresolved_charges(
pool: &PgPool,
key: &gateway_core::policy::ClientApiKeyId,
) -> gateway_admin::ports::store::AdminStoreResult<
Vec<gateway_admin::model::client_keys::UnresolvedClientCharge>,
> {
let rows = sqlx::query("select request_id, started_at, completed_at, state from client_key_charge_events
where client_api_key_id = $1 and (state = 'unknown' or (state = 'pending' and deadline_at <= now()))
order by started_at, request_id limit 200")
.bind(key.as_str()).fetch_all(pool).await.map_err(|_| budget_admin_unavailable())?;
Ok(rows
.into_iter()
.map(
|row| gateway_admin::model::client_keys::UnresolvedClientCharge {
request_id: row.get("request_id"),
started_at: row.get("started_at"),
completed_at: row.get("completed_at"),
state: row.get("state"),
},
)
.collect())
}
pub(super) async fn reconcile_charge(
pool: &PgPool,
command: gateway_admin::model::client_keys::ReconcileClientCharge,
context: &gateway_admin::model::MutationContext,
revision: gateway_admin::model::Revision,
) -> gateway_admin::ports::store::AdminStoreResult<()> {
use gateway_admin::ports::store::{AdminStoreError, AdminStoreErrorKind};
let conflict = || {
AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"client budget",
"charge is missing, active, or already settled with another amount",
)
};
let mut tx = pool.begin().await.map_err(|_| budget_admin_unavailable())?;
sqlx::query("select id from client_api_keys where id = $1 for update")
.bind(command.key_id.as_str())
.fetch_optional(&mut *tx)
.await
.map_err(|_| budget_admin_unavailable())?
.ok_or_else(conflict)?;
let row = sqlx::query("select state, amount_usd::text, coalesce(completed_at, deadline_at) as completed_at, deadline_at <= now() as expired
from client_key_charge_events where request_id = $1 and client_api_key_id = $2 for update")
.bind(&command.request_id).bind(command.key_id.as_str()).fetch_optional(&mut *tx).await.map_err(|_| budget_admin_unavailable())?.ok_or_else(conflict)?;
let state: &str = row.get("state");
if state == "settled" {
return if row.get::<String, _>("amount_usd").parse::<Decimal>().ok()
== Some(command.amount_usd)
{
Ok(())
} else {
Err(conflict())
};
}
if state == "pending" && !row.get::<bool, _>("expired") {
return Err(conflict());
}
let charge = ClientBudgetCharge {
request_id: gateway_core::engine::ModelRequestId::new(command.request_id.clone())
.map_err(|_| conflict())?,
amount_usd: Some(command.amount_usd),
completed_at: row.get::<DateTime<Utc>, _>("completed_at").into(),
};
settle_in_transaction(&mut tx, command.key_id.as_str(), &charge)
.await
.map_err(|_| budget_admin_unavailable())?;
sqlx::query("update client_key_charge_events set reconciled_at = now(), reconciliation_reason = $2 where request_id = $1")
.bind(&command.request_id).bind(command.reason).execute(&mut *tx).await.map_err(|_| budget_admin_unavailable())?;
let audit = crate::mutation_audit(
context,
"reconcile",
"client_key_charge",
&command.request_id,
vec!["amount_usd".to_owned(), "reconciliation_reason".to_owned()],
);
super::admin_security_audit::append_admin_audit_event_in_transaction(
&mut tx,
audit,
crate::store_revision(revision)?,
)
.await
.map_err(|_| budget_admin_unavailable())?;
tx.commit().await.map_err(|_| budget_admin_unavailable())
}
fn budget_admin_unavailable() -> gateway_admin::ports::store::AdminStoreError {
gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"client budget",
"storage unavailable",
)
}
@@ -678,22 +678,6 @@ impl PgAdminClientKeyStore {
#[async_trait]
impl ClientKeyStore for PgAdminClientKeyStore {
async fn unresolved_charges(
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Vec<gateway_admin::model::client_keys::UnresolvedClientCharge>> {
self.required_record(id).await?;
super::client_budgets::unresolved_charges(&self.keys.pool, id).await
}
async fn reconcile_charge(
&self,
command: gateway_admin::model::client_keys::ReconcileClientCharge,
context: &MutationContext,
) -> AdminStoreResult<()> {
let revision = self.revision().await?;
super::client_budgets::reconcile_charge(&self.keys.pool, command, context, revision).await
}
async fn list_client_keys(
&self,
query: AdminClientKeyListQuery,
@@ -22,6 +22,7 @@ mod execution_buffer;
mod observability;
mod ops_events;
mod provider_accounts;
mod proxies;
mod retention;
mod runtime_settings;
mod snapshot;
@@ -38,6 +39,7 @@ pub use execution_buffer::*;
pub use observability::*;
pub use ops_events::*;
pub use provider_accounts::*;
pub use proxies::PgProxyRepository;
pub use retention::*;
pub use runtime_settings::*;
pub use snapshot::*;
@@ -216,6 +216,7 @@ impl PgAdminAccountStore {
settings: Option<AccountImportSettings>,
context: &MutationContext,
action: &str,
outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
) -> AdminStoreResult<CredentialImportResult> {
let provider_kind = prepared.provider_kind.as_str().to_owned();
let accounts = prepared
@@ -233,6 +234,7 @@ impl PgAdminAccountStore {
.accounts
.import_provider_accounts(ImportProviderAccounts {
settings,
outbound_proxy,
scope: ProviderAccountAdminScope {
provider_kind: provider_kind.clone(),
},
@@ -627,6 +629,7 @@ impl AccountStore for PgAdminAccountStore {
command.settings,
context,
"import_document",
command.outbound_proxy,
)
.await
}
@@ -650,6 +653,16 @@ impl AccountStore for PgAdminAccountStore {
command.settings,
context,
"authorize",
command
.pending
.outbound_proxy_id()
.zip(command.pending.outbound_proxy())
.map(
|(id, proxy)| gateway_admin::model::proxies::ImportProxyBinding {
id: id.to_owned(),
proxy: proxy.clone(),
},
),
)
.await?;
let [account_id]: [CoreProviderAccountId; 1] =
@@ -133,15 +133,28 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
} else {
CredentialState::Unknown
};
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin provider account insert"))?;
let proxy_id = match account.outbound_proxy.as_ref() {
Some(proxy) => Some(
super::super::proxies::ensure_proxy_for_url(&mut transaction, proxy, None)
.await?
.0,
),
None => None,
};
sqlx::query(
"insert into provider_accounts (
outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
concurrency_limit, weight, credential_state, provider_quota_json,
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
) values (
$18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
)",
)
@@ -163,9 +176,14 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
.bind(credential_state.as_str())
.bind(account.credential_observed_at)
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
.execute(&self.pool)
.bind(proxy_id)
.execute(&mut *transaction)
.await
.map_err(|_| postgres_unavailable("insert provider account"))?;
transaction
.commit()
.await
.map_err(|_| postgres_unavailable("commit provider account insert"))?;
Ok(())
}
@@ -443,6 +461,22 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
let result = async {
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
if let Some(binding) = &command.outbound_proxy {
let (_, current) = super::super::proxies::resolve_proxy_selection(
&mut transaction,
&gateway_admin::model::proxies::AccountProxySelection::Saved(
binding.id.clone(),
),
)
.await?;
if current.as_ref() != Some(&binding.proxy) {
return Err(StoreError::Conflict {
entity: "outbound proxy",
id: binding.id.clone(),
kind: ConflictKind::StaleRevision,
});
}
}
let mut account_ids = Vec::with_capacity(command.accounts.len());
for account in &command.accounts {
account_ids
@@ -701,15 +735,23 @@ pub(crate) async fn upsert_provider_account_in_transaction(
} else {
CredentialState::Unknown
};
let proxy_id = match account.outbound_proxy.as_ref() {
Some(proxy) => Some(
super::super::proxies::ensure_proxy_for_url(transaction, proxy, None)
.await?
.0,
),
None => None,
};
let imported_id = sqlx::query_scalar::<_, String>(
"insert into provider_accounts (
outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
concurrency_limit, weight, credential_state, provider_quota_json,
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
) values (
$18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
)
on conflict (
@@ -723,6 +765,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
authentication_kind = excluded.authentication_kind,
provider_credentials_json = excluded.provider_credentials_json,
outbound_proxy_url = coalesce(excluded.outbound_proxy_url, provider_accounts.outbound_proxy_url),
outbound_proxy_id = coalesce(excluded.outbound_proxy_id, provider_accounts.outbound_proxy_id),
credential_revision = provider_accounts.credential_revision + 1,
has_refresh_token = excluded.has_refresh_token,
access_token_expires_at = excluded.access_token_expires_at,
@@ -759,6 +802,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
.bind(credential_state.as_str())
.bind(account.credential_observed_at)
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
.bind(proxy_id)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
@@ -865,12 +909,19 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
enabled: bool,
concurrency_limit: Option<AccountConcurrencyLimit>,
weight: AccountWeight,
outbound_proxy: Option<&Option<gateway_core::account::OutboundProxy>>,
outbound_proxy: Option<&gateway_admin::model::proxies::AccountProxySelection>,
) -> StoreResult<()> {
let (proxy_id, proxy) = match outbound_proxy {
Some(selection) => {
super::super::proxies::resolve_proxy_selection(transaction, selection).await?
}
None => (None, None),
};
let updated = sqlx::query_scalar::<_, String>(
"update provider_accounts
set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = greatest(now(), updated_at),
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end,
outbound_proxy_id = case when $5 then $7 else outbound_proxy_id end
where id = any($1::text[])
returning id",
)
@@ -879,11 +930,8 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
.bind(concurrency_limit.map(|limit| i64::from(limit.get())))
.bind(i16::try_from(weight.get()).map_err(|_| invalid("invalid weight"))?)
.bind(outbound_proxy.is_some())
.bind(
outbound_proxy
.and_then(Option::as_ref)
.map(|proxy| proxy.expose_url()),
)
.bind(proxy.as_ref().map(gateway_core::account::OutboundProxy::expose_url))
.bind(proxy_id)
.fetch_all(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("set provider accounts state in admin transaction"))?
@@ -233,6 +233,7 @@ impl NewProviderAccount {
#[derive(Clone)]
pub struct ImportProviderAccounts {
pub settings: Option<AccountImportSettings>,
pub outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
pub scope: ProviderAccountAdminScope,
pub accounts: Vec<NewProviderAccount>,
pub audit: AdminAuditEvent,
@@ -313,7 +314,7 @@ impl fmt::Debug for RotateProviderAccount {
#[derive(Debug, Clone)]
pub struct BatchUpdateProviderAccountsAdmin {
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
pub account_ids: Vec<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
@@ -0,0 +1,678 @@
//! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use gateway_admin::{
model::{
MutationContext, Revision as AdminRevision,
account_groups::{AccountGroupColor, AccountGroupRef},
proxies::*,
},
ports::{
proxy::{ProxyImportGuard, ProxyImportReservation, ProxyStore},
store::{AdminStoreError, AdminStoreResult},
},
};
use gateway_core::{
account::{OutboundProxy, ProviderAccountId},
routing::AccountGroupId,
};
use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row as _, Transaction, postgres::PgRow};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction};
use crate::{
ConflictKind, Revision, StoreError, StoreResult, admin_revision, admin_store_error,
mutation_audit, postgres_unavailable,
};
const ENTITY: &str = "outbound proxy";
const SELECT: &str = "select p.*,
(select count(*) from provider_accounts a where a.outbound_proxy_id = p.id) as account_count
from outbound_proxies p";
#[derive(Clone)]
pub struct PgProxyRepository {
pool: PgPool,
import_slots: Arc<Semaphore>,
}
impl PgProxyRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self {
pool,
import_slots: Arc::new(Semaphore::new(4)),
}
}
}
struct PgProxyImportGuard {
// 独立会话持有咨询锁,避免空闲事务超时,也不给提交事务占用的连接池制造死锁。
// 每个仓储最多增加四个会话;取消请求或进程退出时,关闭连接会自动释放锁。
_connection: PgConnection,
_slot: OwnedSemaphorePermit,
}
impl ProxyImportGuard for PgProxyImportGuard {}
async fn exclude_active_imports(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
) -> StoreResult<()> {
let acquired: bool =
sqlx::query_scalar("select pg_try_advisory_xact_lock(hashtextextended($1, 739219))")
.bind(id)
.fetch_one(&mut **transaction)
.await
.map_err(|_| unavailable())?;
if !acquired {
return Err(conflict(id));
}
Ok(())
}
fn store_error(error: StoreError) -> AdminStoreError {
admin_store_error(ENTITY, error)
}
fn unavailable() -> StoreError {
postgres_unavailable("outbound proxy operation")
}
fn conflict(id: &str) -> StoreError {
StoreError::Conflict {
entity: ENTITY,
id: id.to_owned(),
kind: ConflictKind::InvalidTransition,
}
}
fn invalid() -> StoreError {
StoreError::InvalidData {
entity: ENTITY,
message: "invalid proxy record".to_owned(),
}
}
fn record(row: PgRow) -> StoreResult<ProxyRecord> {
let success: Option<bool> = row.try_get("last_test_success").map_err(|_| invalid())?;
let ip: Option<String> = row.try_get("last_test_ip").map_err(|_| invalid())?;
let latency: Option<i64> = row.try_get("last_test_latency_ms").map_err(|_| invalid())?;
Ok(ProxyRecord {
id: row.try_get("id").map_err(|_| invalid())?,
name: row.try_get("name").map_err(|_| invalid())?,
proxy: OutboundProxy::parse(
&row.try_get::<String, _>("proxy_url")
.map_err(|_| invalid())?,
)
.map_err(|_| invalid())?,
revision: AdminRevision::new(
u64::try_from(row.try_get::<i64, _>("revision").map_err(|_| invalid())?)
.map_err(|_| invalid())?,
)
.map_err(|_| invalid())?,
account_count: u64::try_from(
row.try_get::<i64, _>("account_count")
.map_err(|_| invalid())?,
)
.map_err(|_| invalid())?,
last_test_at: row.try_get("last_test_at").map_err(|_| invalid())?,
last_test: success
.map(|success| -> StoreResult<_> {
Ok(ProxyTestResult {
success,
latency_ms: u64::try_from(latency.ok_or_else(invalid)?)
.map_err(|_| invalid())?,
exit_ip: ip.map(|ip| ip.parse().map_err(|_| invalid())).transpose()?,
message: row
.try_get::<Option<String>, _>("last_test_message")
.map_err(|_| invalid())?
.unwrap_or_default(),
})
})
.transpose()?,
created_at: row.try_get("created_at").map_err(|_| invalid())?,
updated_at: row.try_get("updated_at").map_err(|_| invalid())?,
})
}
async fn lock_url(
transaction: &mut Transaction<'_, Postgres>,
proxy: &OutboundProxy,
) -> StoreResult<()> {
sqlx::query("select pg_advisory_xact_lock(hashtextextended($1, 739218))")
.bind(proxy.expose_url())
.execute(&mut **transaction)
.await
.map_err(|_| unavailable())?;
Ok(())
}
/// 导入和旧版 URL 写入在账号事务内登记到共享代理目录。
pub(crate) async fn ensure_proxy_for_url(
transaction: &mut Transaction<'_, Postgres>,
proxy: &OutboundProxy,
name: Option<&str>,
) -> StoreResult<(String, bool)> {
lock_url(transaction, proxy).await?;
if let Some(id) = sqlx::query_scalar::<_, String>("select id from outbound_proxies where proxy_url = $1 order by created_at, id limit 1 for share")
.bind(proxy.expose_url()).fetch_optional(&mut **transaction).await.map_err(|_| unavailable())? {
return Ok((id, false));
}
let id = format!("proxy_{}", uuid::Uuid::now_v7().simple());
let generated_name = format!("Imported proxy {}", &id[id.len() - 8..]);
sqlx::query("insert into outbound_proxies (id, name, proxy_url) values ($1, $2, $3)")
.bind(&id)
.bind(name.unwrap_or(&generated_name))
.bind(proxy.expose_url())
.execute(&mut **transaction)
.await
.map_err(|_| unavailable())?;
Ok((id, true))
}
pub(crate) async fn resolve_proxy_selection(
transaction: &mut Transaction<'_, Postgres>,
selection: &AccountProxySelection,
) -> StoreResult<(Option<String>, Option<OutboundProxy>)> {
match selection {
AccountProxySelection::Direct => Ok((None, None)),
AccountProxySelection::Url(proxy) => {
let (id, _) = ensure_proxy_for_url(transaction, proxy, None).await?;
Ok((Some(id), Some(proxy.clone())))
}
AccountProxySelection::Saved(id) => {
let (value, tested) = sqlx::query_as::<_, (String, Option<bool>)>(
"select proxy_url, last_test_success from outbound_proxies where id = $1 for share",
)
.bind(id)
.fetch_optional(&mut **transaction)
.await
.map_err(|_| unavailable())?
.ok_or_else(|| StoreError::NotFound {
entity: ENTITY,
id: id.clone(),
})?;
if tested != Some(true) {
return Err(conflict(id));
}
Ok((
Some(id.clone()),
Some(OutboundProxy::parse(&value).map_err(|_| invalid())?),
))
}
}
}
async fn audit(
transaction: &mut Transaction<'_, Postgres>,
context: &MutationContext,
action: &str,
id: &str,
fields: &[&str],
revision: Revision,
) -> StoreResult<()> {
append_admin_audit_event_in_transaction(
transaction,
mutation_audit(
context,
action,
"outbound_proxy",
id,
fields.iter().map(|value| (*value).to_owned()).collect(),
),
revision,
)
.await
}
#[async_trait]
impl ProxyStore for PgProxyRepository {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
let revision = bump_config_revision_in_transaction(&mut transaction)
.await
.map_err(store_error)?;
// 在同一条更新中校验绑定,避免旧弹窗清除账号后来选择的其他代理。
let updated = sqlx::query(
"update provider_accounts
set outbound_proxy_id = null, outbound_proxy_url = null,
updated_at = greatest(now(), updated_at)
where id = $1 and outbound_proxy_id = $2",
)
.bind(account_id.as_str())
.bind(proxy_id)
.execute(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
if updated.rows_affected() != 1 {
return Err(store_error(conflict(proxy_id)));
}
append_admin_audit_event_in_transaction(
&mut transaction,
mutation_audit(
context,
"update",
"provider_account",
account_id.as_str(),
vec!["outbound_proxy".to_owned()],
),
revision,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
admin_revision(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if query.page == 0 {
return Err(store_error(invalid()));
}
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
// 数量和当前页共用只读快照,避免绑定变化使同一次响应的分页事实不一致。
sqlx::query("set transaction isolation level repeatable read, read only")
.execute(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
let total: i64 = sqlx::query_scalar(
"select count(a.id) from outbound_proxies p
left join provider_accounts a on a.outbound_proxy_id = p.id
and (strpos(lower(a.name), lower($2)) > 0
or strpos(lower(coalesce(a.email, '')), lower($2)) > 0)
where p.id = $1 group by p.id",
)
.bind(&query.proxy_id)
.bind(&query.search)
.fetch_optional(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?
.ok_or_else(|| {
store_error(StoreError::NotFound {
entity: ENTITY,
id: query.proxy_id.clone(),
})
})?;
let mut items = sqlx::query(
"select id, name, email, provider_kind, authentication_kind, plan_type, enabled from provider_accounts
where outbound_proxy_id = $1 and (strpos(lower(name), lower($2)) > 0
or strpos(lower(coalesce(email, '')), lower($2)) > 0)
order by name, id limit $3 offset $4",
)
.bind(&query.proxy_id)
.bind(&query.search)
.bind(i64::from(query.page_size.get()))
.bind(i64::from(query.page - 1) * i64::from(query.page_size.get()))
.fetch_all(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?
.into_iter()
.map(|row| {
Ok(ProxyAccountRef {
id: row.try_get("id").map_err(|_| invalid())?,
name: row.try_get("name").map_err(|_| invalid())?,
email: row.try_get("email").map_err(|_| invalid())?,
provider_kind: row.try_get("provider_kind").map_err(|_| invalid())?,
authentication_kind: row.try_get("authentication_kind").map_err(|_| invalid())?,
plan_type: row.try_get("plan_type").map_err(|_| invalid())?,
plan_type_display: None,
groups: Vec::new(),
enabled: row.try_get("enabled").map_err(|_| invalid())?,
})
})
.collect::<StoreResult<Vec<_>>>()
.map_err(store_error)?;
if !items.is_empty() {
// 仅批量加载当前页的分组,并沿用同一快照,避免逐账号查询。
let account_ids: Vec<&str> = items.iter().map(|account| account.id.as_str()).collect();
let rows = sqlx::query_as::<_, (String, String, String, String, bool)>(
"select m.provider_account_id, g.id, g.name, g.color, g.enabled
from account_group_accounts m join account_groups g on g.id = m.account_group_id
where m.provider_account_id = any($1::text[])
order by m.provider_account_id, g.id",
)
.bind(&account_ids)
.fetch_all(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
let mut groups = BTreeMap::<String, Vec<AccountGroupRef>>::new();
for (account_id, group_id, name, color, enabled) in rows {
groups.entry(account_id).or_default().push(AccountGroupRef {
id: AccountGroupId::new(group_id).map_err(|_| store_error(invalid()))?,
name,
color: AccountGroupColor::parse(&color)
.ok_or_else(|| store_error(invalid()))?,
enabled,
});
}
for account in &mut items {
account.groups = groups.remove(&account.id).unwrap_or_default();
}
}
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
Ok(ProxyAccountPage {
items,
total: u64::try_from(total).map_err(|_| store_error(invalid()))?,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation> {
let slot = self
.import_slots
.clone()
.try_acquire_owned()
.map_err(|_| store_error(conflict(id)))?;
let mut connection = self
.pool
.acquire()
.await
.map_err(|_| store_error(unavailable()))?
.detach();
let acquired: bool =
sqlx::query_scalar("select pg_try_advisory_lock_shared(hashtextextended($1, 739219))")
.bind(id)
.fetch_one(&mut connection)
.await
.map_err(|_| store_error(unavailable()))?;
if !acquired {
return Err(store_error(conflict(id)));
}
let record = self.get(id).await?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(store_error(conflict(id)));
}
Ok(ProxyImportReservation {
binding: ImportProxyBinding {
id: record.id,
proxy: record.proxy,
},
guard: Box::new(PgProxyImportGuard {
_connection: connection,
_slot: slot,
}),
})
}
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
if query.page == 0 {
return Err(store_error(invalid()));
}
let total: i64 = sqlx::query_scalar(
"select count(*) from outbound_proxies where strpos(lower(name), lower($1)) > 0",
)
.bind(&query.search)
.fetch_one(&self.pool)
.await
.map_err(|_| store_error(unavailable()))?;
let mut builder = QueryBuilder::<Postgres>::new(SELECT);
builder
.push(" where strpos(lower(p.name), lower(")
.push_bind(&query.search)
.push(")) > 0 order by p.created_at desc, p.id limit ")
.push_bind(i64::from(query.page_size.get()))
.push(" offset ")
.push_bind(i64::from(query.page - 1) * i64::from(query.page_size.get()));
let items = builder
.build()
.fetch_all(&self.pool)
.await
.map_err(|_| store_error(unavailable()))?
.into_iter()
.map(record)
.collect::<StoreResult<Vec<_>>>()
.map_err(store_error)?;
Ok(ProxyPage {
items,
total: u64::try_from(total).map_err(|_| store_error(invalid()))?,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
let mut builder = QueryBuilder::<Postgres>::new(SELECT);
builder.push(" where p.id = ").push_bind(id);
let row = builder
.build()
.fetch_optional(&self.pool)
.await
.map_err(|_| store_error(unavailable()))?
.ok_or_else(|| {
store_error(StoreError::NotFound {
entity: ENTITY,
id: id.to_owned(),
})
})?;
record(row).map_err(store_error)
}
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
let revision = bump_config_revision_in_transaction(&mut transaction)
.await
.map_err(store_error)?;
let (id, created) =
ensure_proxy_for_url(&mut transaction, &command.proxy, Some(&command.name))
.await
.map_err(store_error)?;
if !created {
return Err(store_error(conflict(&id)));
}
audit(
&mut transaction,
context,
"create",
&id,
&["name", "proxy_url"],
revision,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
Ok(ProxyMutation {
config_revision: admin_revision(revision)?,
record: self.get(&id).await?,
})
}
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
exclude_active_imports(&mut transaction, &command.id)
.await
.map_err(store_error)?;
let revision = bump_config_revision_in_transaction(&mut transaction)
.await
.map_err(store_error)?;
if let Some(proxy) = &command.proxy {
lock_url(&mut transaction, proxy)
.await
.map_err(store_error)?;
let duplicate: bool = sqlx::query_scalar(
"select exists(select 1 from outbound_proxies where proxy_url = $1 and id <> $2)",
)
.bind(proxy.expose_url())
.bind(&command.id)
.fetch_one(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
if duplicate {
return Err(store_error(conflict(&command.id)));
}
}
let changed = sqlx::query(
"update outbound_proxies set name = $3, proxy_url = coalesce($4, proxy_url), revision = revision + 1, updated_at = now(),
last_test_at = case when $4 is not null and $4 <> proxy_url then null else last_test_at end,
last_test_success = case when $4 is not null and $4 <> proxy_url then null else last_test_success end,
last_test_latency_ms = case when $4 is not null and $4 <> proxy_url then null else last_test_latency_ms end,
last_test_ip = case when $4 is not null and $4 <> proxy_url then null else last_test_ip end,
last_test_message = case when $4 is not null and $4 <> proxy_url then null else last_test_message end
where id = $1 and revision = $2")
.bind(&command.id).bind(i64::try_from(command.revision.get()).map_err(|_| store_error(invalid()))?)
.bind(&command.name).bind(command.proxy.as_ref().map(OutboundProxy::expose_url))
.execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
if changed.rows_affected() != 1 {
return Err(store_error(conflict(&command.id)));
}
sqlx::query("update provider_accounts a set outbound_proxy_url = p.proxy_url, updated_at = greatest(now(), a.updated_at) from outbound_proxies p where p.id = $1 and a.outbound_proxy_id = p.id and a.outbound_proxy_url is distinct from p.proxy_url")
.bind(&command.id).execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
audit(
&mut transaction,
context,
"update",
&command.id,
&["name", "proxy_url"],
revision,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
Ok(ProxyMutation {
config_revision: admin_revision(revision)?,
record: self.get(&command.id).await?,
})
}
async fn delete(
&self,
id: &str,
revision: AdminRevision,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
exclude_active_imports(&mut transaction, id)
.await
.map_err(store_error)?;
let config_revision = bump_config_revision_in_transaction(&mut transaction)
.await
.map_err(store_error)?;
let result = sqlx::query("delete from outbound_proxies where id = $1 and revision = $2")
.bind(id)
.bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?)
.execute(&mut *transaction)
.await
.map_err(|error| {
// PostgreSQL 18 为 RESTRICT 返回不同于普通外键违规的错误码。
if error.as_database_error().is_some_and(|error| {
error.is_foreign_key_violation() || error.code().as_deref() == Some("23001")
}) {
store_error(conflict(id))
} else {
store_error(unavailable())
}
})?;
if result.rows_affected() != 1 {
return Err(store_error(conflict(id)));
}
audit(
&mut transaction,
context,
"delete",
id,
&[],
config_revision,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
admin_revision(config_revision)
}
async fn record_test(
&self,
id: &str,
revision: AdminRevision,
result: ProxyTestResult,
context: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
exclude_active_imports(&mut transaction, id)
.await
.map_err(store_error)?;
let updated = sqlx::query("update outbound_proxies set last_test_at = now(), last_test_success = $3, last_test_latency_ms = $4, last_test_ip = $5, last_test_message = $6 where id = $1 and revision = $2")
.bind(id).bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?)
.bind(result.success).bind(i64::try_from(result.latency_ms).map_err(|_| store_error(invalid()))?)
.bind(result.exit_ip.map(|ip| ip.to_string())).bind(result.message)
.execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
if updated.rows_affected() != 1 {
return Err(store_error(conflict(id)));
}
let current: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
let current = Revision::new(u64::try_from(current).map_err(|_| store_error(invalid()))?)
.map_err(store_error)?;
audit(
&mut transaction,
context,
"test",
id,
&["last_test"],
current,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
self.get(id).await
}
}
@@ -1,17 +1,14 @@
use std::time::{Duration, SystemTime};
use std::time::SystemTime;
use chrono::{DateTime, Utc};
use gateway_admin::{
model::{
MutationActor, MutationContext,
client_keys::{ReconcileClientCharge, UpdateClientKey},
},
model::{MutationActor, MutationContext, client_keys::UpdateClientKey},
ports::store::ClientKeyStore as _,
};
use gateway_core::{
engine::{
ModelRequestId,
budget::{ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetPort, ClientBudgetStatus},
budget::{ClientBudgetCharge, ClientBudgetPort, ClientBudgetStatus},
},
error::GatewayErrorKind,
policy::{ClientApiKeyId, RateLimits},
@@ -23,18 +20,15 @@ use gateway_store::postgres::{
use super::TestDatabase;
fn admission(key: &str, request: &str) -> ClientBudgetAdmission {
ClientBudgetAdmission {
key_id: ClientApiKeyId::new(key).unwrap(),
request_id: ModelRequestId::new(format!("req_{request}")).unwrap(),
deadline_at: SystemTime::now() + Duration::from_secs(600),
}
fn key_id(key: &str) -> ClientApiKeyId {
ClientApiKeyId::new(key).unwrap()
}
fn charge(request: &str, amount: Option<&str>) -> ClientBudgetCharge {
fn charge(key: &str, request: &str, amount: &str) -> ClientBudgetCharge {
ClientBudgetCharge {
key_id: key_id(key),
request_id: ModelRequestId::new(format!("req_{request}")).unwrap(),
amount_usd: amount.map(|value| value.parse().unwrap()),
amount_usd: amount.parse().unwrap(),
completed_at: SystemTime::now(),
}
}
@@ -65,7 +59,7 @@ async fn status(database: &TestDatabase, key: &str) -> ClientBudgetStatus {
fn context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: "reconcile-test".to_owned(),
request_id: "budget-test".to_owned(),
}
}
@@ -79,16 +73,15 @@ async fn budgets_settle_exactly_once_and_enforce_each_threshold_across_store_ins
let first = PgClientBudgetStore::new(database.pool.clone());
let second = PgClientBudgetStore::new(database.pool.clone());
for (key, prefix, amount) in [("day", "d", "0.1"), ("week", "w", "0.1")] {
for index in 0..3 {
let id = format!("{prefix}-{index}");
// Admitted work can finish above the threshold; no estimated cost reservation.
first.admit(admission(key, &id)).await.unwrap();
for _ in 0..3 {
// 已准入请求可完成并超过限额,不预占估算费用。
first.admit(key_id(key)).await.unwrap();
}
for index in 0..3 {
let id = format!("{prefix}-{index}");
let (a, b) = tokio::join!(
first.settle(charge(&id, Some(amount))),
second.settle(charge(&id, Some(amount)))
first.settle(charge(key, &id, amount)),
second.settle(charge(key, &id, amount))
);
a.unwrap();
b.unwrap();
@@ -97,31 +90,23 @@ async fn budgets_settle_exactly_once_and_enforce_each_threshold_across_store_ins
let day = status(&database, "day").await;
assert_eq!(day.daily_used_usd.canonical(), "0.3");
assert_eq!(day.weekly_used_usd.canonical(), "0.3");
let day_error = second
.admit(admission("day", "d-rejected"))
.await
.unwrap_err();
let day_error = second.admit(key_id("day")).await.unwrap_err();
assert_eq!(day_error.kind(), GatewayErrorKind::RateLimited);
assert_eq!(
day_error.client_error_code(),
Some("key_daily_budget_exceeded")
);
assert!(day_error.retry_after().is_some());
let week_error = first
.admit(admission("week", "w-rejected"))
.await
.unwrap_err();
let week_error = first.admit(key_id("week")).await.unwrap_err();
assert_eq!(
week_error.client_error_code(),
Some("key_weekly_budget_exceeded")
);
let rejected_count: i64 = sqlx::query_scalar(
"select count(*) from client_key_charge_events where request_id like '%rejected'",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(rejected_count, 0);
let event_count: i64 = sqlx::query_scalar("select count(*) from client_key_charge_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(event_count, 6, "rejected admission must not create charges");
database.close().await;
}
@@ -132,17 +117,17 @@ async fn window_rollover_is_shanghai_midnight_and_seven_days_with_late_settlemen
};
seed(&database, "key", "1", "2").await;
let store = PgClientBudgetStore::new(database.pool.clone());
store.admit(admission("key", "first")).await.unwrap();
store.admit(key_id("key")).await.unwrap();
let (day_start, day_end, week_end): (DateTime<Utc>, DateTime<Utc>, DateTime<Utc>) =
sqlx::query_as("select daily_start, daily_end, weekly_end from client_key_budget_windows where client_api_key_id = 'key'")
.fetch_one(&database.pool).await.unwrap();
assert_eq!(day_start.timestamp().rem_euclid(86400), 16 * 3600);
assert_eq!((day_end - day_start).num_hours(), 24);
assert_eq!((week_end - day_start).num_hours(), 168);
store.settle(charge("first", Some("1"))).await.unwrap();
store.settle(charge("key", "first", "1")).await.unwrap();
sqlx::query("update client_key_budget_windows set daily_start = daily_start - interval '1 day', daily_end = daily_start")
.execute(&database.pool).await.unwrap();
store.admit(admission("key", "second")).await.unwrap();
store.admit(key_id("key")).await.unwrap();
assert_eq!(
status(&database, "key").await.daily_used_usd.canonical(),
"0"
@@ -151,16 +136,16 @@ async fn window_rollover_is_shanghai_midnight_and_seven_days_with_late_settlemen
status(&database, "key").await.weekly_used_usd.canonical(),
"1"
);
store.settle(charge("second", Some("1"))).await.unwrap();
store.settle(charge("key", "second", "1")).await.unwrap();
sqlx::query("update client_key_budget_windows set daily_end = now() - interval '1 second', weekly_end = now() - interval '1 second'")
.execute(&database.pool).await.unwrap();
let virtual_reset = status(&database, "key").await;
assert_eq!(virtual_reset.daily_used_usd.canonical(), "0");
assert_eq!(virtual_reset.weekly_used_usd.canonical(), "0");
store.admit(admission("key", "after-reset")).await.unwrap();
store.admit(key_id("key")).await.unwrap();
let old = ClientBudgetCharge {
completed_at: (day_start - chrono::Duration::seconds(1)).into(),
..charge("after-reset", Some("0.9"))
..charge("key", "after-reset", "0.9")
};
store.settle(old).await.unwrap();
let reset = status(&database, "key").await;
@@ -171,103 +156,80 @@ async fn window_rollover_is_shanghai_midnight_and_seven_days_with_late_settlemen
}
#[tokio::test]
async fn unknown_and_orphan_charges_block_limited_keys_until_audited_reconciliation() {
let Some(database) = TestDatabase::create("budgets_unknown").await else {
async fn zero_cost_and_interrupted_requests_never_block_limited_keys() {
let Some(database) = TestDatabase::create("budgets_zero").await else {
return;
};
seed(&database, "key", "1", "5").await;
seed(&database, "other", "0", "0").await;
let store = PgClientBudgetStore::new(database.pool.clone());
let admin = PgAdminClientKeyStore::new(database.pool.clone());
store.admit(admission("key", "unknown")).await.unwrap();
store.settle(charge("unknown", None)).await.unwrap();
store.admit(key_id("key")).await.unwrap();
store.settle(charge("key", "no-cost", "0")).await.unwrap();
// 模拟准入后进程退出,没有费用可结算;重启后仍应允许同一 Key 使用。
store.admit(key_id("key")).await.unwrap();
let restarted = PgClientBudgetStore::new(database.pool.clone());
restarted.admit(key_id("key")).await.unwrap();
let events: i64 = sqlx::query_scalar("select count(*) from client_key_charge_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(events, 1, "admission must not leave pending charges");
assert_eq!(
store
.admit(admission("key", "blocked"))
.await
.unwrap_err()
.client_error_code(),
Some("key_budget_unresolved")
status(&database, "key").await.daily_used_usd.canonical(),
"0"
);
let key_id = ClientApiKeyId::new("key").unwrap();
assert_eq!(admin.unresolved_charges(&key_id).await.unwrap().len(), 1);
assert_eq!(status(&database, "key").await.unresolved_requests, 1);
let reconcile = |key: &str, request: &str, amount: &str| ReconcileClientCharge {
key_id: ClientApiKeyId::new(key).unwrap(),
request_id: format!("req_{request}"),
amount_usd: amount.parse().unwrap(),
reason: "verified against upstream usage".to_owned(),
};
assert!(
admin
.reconcile_charge(reconcile("other", "unknown", "0.4"), &context())
.await
.is_err()
);
admin
.reconcile_charge(reconcile("key", "unknown", "0.4"), &context())
restarted
.settle(charge("key", "known", "0.4"))
.await
.unwrap();
admin
.reconcile_charge(reconcile("key", "unknown", "0.4"), &context())
.await
.unwrap();
assert!(
admin
.reconcile_charge(reconcile("key", "unknown", "0.5"), &context())
.await
.is_err()
);
assert_eq!(
status(&database, "key").await.daily_used_usd.canonical(),
"0.4"
);
let audits: i64 = sqlx::query_scalar(
"select count(*) from admin_audit_events where entity_kind = 'client_key_charge'",
database.close().await;
}
#[tokio::test]
async fn transient_settlement_failure_rolls_back_and_retries_exact_cost_before_admission() {
let Some(database) = TestDatabase::create("budgets_retry").await else {
return;
};
seed(&database, "key", "1", "5").await;
let store = PgClientBudgetStore::new(database.pool.clone());
store.admit(key_id("key")).await.unwrap();
// 在费用事件插入后让窗口写入失败,验证整个事务回滚。
sqlx::raw_sql(
"create function reject_budget_update() returns trigger language plpgsql as $$
begin
if new.daily_used_usd > 0 then raise exception 'temporary test failure'; end if;
return new;
end $$;
create trigger reject_budget_update before update on client_key_budget_windows
for each row execute function reject_budget_update();",
)
.fetch_one(&database.pool)
.execute(&database.pool)
.await
.unwrap();
assert_eq!(audits, 1);
store.admit(admission("key", "orphan")).await.unwrap();
assert!(
admin
.reconcile_charge(reconcile("key", "orphan", "0"), &context())
.await
.is_err()
);
sqlx::query("update client_key_charge_events set deadline_at = now() - interval '1 second' where request_id = 'req_orphan'")
.execute(&database.pool).await.unwrap();
let restarted = PgClientBudgetStore::new(database.pool.clone());
assert!(store.settle(charge("key", "retry", "1.25")).await.is_err());
let events: i64 = sqlx::query_scalar("select count(*) from client_key_charge_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(events, 0);
assert_eq!(
restarted
.admit(admission("key", "restart-blocked"))
.await
.unwrap_err()
.client_error_code(),
Some("key_budget_unresolved")
status(&database, "key").await.daily_used_usd.canonical(),
"0"
);
admin
.reconcile_charge(reconcile("key", "orphan", "0"), &context())
.await
.unwrap();
restarted
.admit(admission("key", "recovered"))
.await
.unwrap();
assert_eq!(status(&database, "key").await.unresolved_requests, 0);
store
.admit(admission("other", "unlimited-unknown"))
.await
.unwrap();
store
.settle(charge("unlimited-unknown", None))
.await
.unwrap();
store
.admit(admission("other", "unlimited-next"))
sqlx::query("drop trigger reject_budget_update on client_key_budget_windows")
.execute(&database.pool)
.await
.unwrap();
let error = store.admit(key_id("key")).await.unwrap_err();
assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded"));
store.settle(charge("key", "retry", "1.25")).await.unwrap();
assert_eq!(
status(&database, "key").await.daily_used_usd.canonical(),
"1.25"
);
database.close().await;
}
@@ -279,9 +241,9 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
seed(&database, "key", "0", "0").await;
let store = PgClientBudgetStore::new(database.pool.clone());
let admin = PgAdminClientKeyStore::new(database.pool.clone());
store.admit(admission("key", "unlimited")).await.unwrap();
store.admit(key_id("key")).await.unwrap();
store
.settle(charge("unlimited", Some("2.75")))
.settle(charge("key", "unlimited", "2.75"))
.await
.unwrap();
let update = UpdateClientKey {
@@ -317,7 +279,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
assert_eq!(current.daily_used_usd.canonical(), "2.75");
assert_eq!(
store
.admit(admission("key", "blocked"))
.admit(key_id("key"))
.await
.unwrap_err()
.client_error_code(),
@@ -334,17 +296,13 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
)
.await
.unwrap();
store.admit(admission("key", "allowed")).await.unwrap();
store.admit(key_id("key")).await.unwrap();
sqlx::query("update client_api_keys set enabled = false where id = 'key'")
.execute(&database.pool)
.await
.unwrap();
assert_eq!(
store
.admit(admission("key", "disabled"))
.await
.unwrap_err()
.kind(),
store.admit(key_id("key")).await.unwrap_err().kind(),
GatewayErrorKind::PolicyDenied
);
sqlx::query("delete from client_api_keys where id = 'key'")
@@ -352,14 +310,10 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
.await
.unwrap();
assert_eq!(
store
.admit(admission("key", "deleted"))
.await
.unwrap_err()
.kind(),
store.admit(key_id("key")).await.unwrap_err().kind(),
GatewayErrorKind::Unauthorized
);
store.settle(charge("allowed", Some("1"))).await.unwrap();
store.settle(charge("key", "allowed", "1")).await.unwrap();
database.close().await;
}
@@ -372,12 +326,12 @@ async fn budget_database_outage_fails_closed() {
database.pool.close().await;
assert_eq!(
store
.admit(admission("key", "offline"))
.admit(key_id("key"))
.await
.unwrap_err()
.client_error_code(),
Some("key_budget_unavailable")
);
assert!(store.settle(charge("offline", Some("1"))).await.is_err());
assert!(store.settle(charge("key", "offline", "1")).await.is_err());
database.close().await;
}
@@ -22,6 +22,7 @@ mod health;
mod observability;
mod ops_events;
mod provider_accounts;
mod proxies;
mod query_budget;
mod retention;
mod runtime_settings;
@@ -235,6 +236,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
"client_key_charge_events",
"model_requests",
"ops_events",
"outbound_proxies",
"provider_accounts",
"runtime_settings",
]
@@ -1224,7 +1224,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
store
.update_account(
UpdateAccount {
outbound_proxy: Some(Some(
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url(
gateway_core::account::OutboundProxy::parse(
"socks5h://next:new-secret@127.0.0.1:1080",
)
@@ -1240,7 +1240,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
store
.update_account(
UpdateAccount {
outbound_proxy: Some(None),
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Direct),
..command
},
&context,
@@ -1546,6 +1546,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
@@ -1569,6 +1570,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![updated],
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
@@ -1604,6 +1606,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope,
accounts: vec![account("acct_admin_upsert", "another-user")],
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
@@ -1693,6 +1696,111 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
database.close().await;
}
#[tokio::test]
async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() {
use gateway_admin::{
model::proxies::{NewProxy, ProxyTestResult, UpdateProxy},
ports::proxy::ProxyStore,
};
let Some(database) = TestDatabase::create("oauth_proxy_guard").await else {
return;
};
let proxies = gateway_store::postgres::PgProxyRepository::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "oauth-proxy".to_owned(),
};
let original = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:8080").unwrap();
let saved = proxies
.create(
NewProxy {
name: "OAuth".to_owned(),
proxy: original.clone(),
},
&context,
)
.await
.unwrap()
.record;
let success = ProxyTestResult {
success: true,
latency_ms: 1,
exit_ip: Some("203.0.113.5".parse().unwrap()),
message: "Connected".to_owned(),
};
proxies
.record_test(&saved.id, saved.revision, success.clone(), &context)
.await
.unwrap();
let replacement = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:9090").unwrap();
let edited = proxies
.update(
UpdateProxy {
id: saved.id.clone(),
revision: saved.revision,
name: saved.name,
proxy: Some(replacement.clone()),
},
&context,
)
.await
.unwrap()
.record;
proxies
.record_test(&edited.id, edited.revision, success, &context)
.await
.unwrap();
let repository = PgProviderAccountRepository::new(database.pool.clone());
let mut candidate = account("acct_oauth_proxy", "oauth-user");
candidate.outbound_proxy = Some(original);
let command = |candidate: NewProviderAccount| ImportProviderAccounts {
settings: None,
outbound_proxy: Some(gateway_admin::model::proxies::ImportProxyBinding {
id: saved.id.clone(),
proxy: candidate.outbound_proxy.clone().unwrap(),
}),
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![candidate],
audit: audit("audit_oauth_proxy", "authorize", "acct_oauth_proxy"),
};
assert!(
repository
.import_provider_accounts(command(candidate.clone()))
.await
.is_err()
);
assert_eq!(account_count(&database.pool, "acct_oauth_proxy").await, 0);
candidate.outbound_proxy = Some(replacement);
repository
.import_provider_accounts(command(candidate))
.await
.unwrap();
assert_eq!(
proxies
.list_accounts(gateway_admin::model::proxies::ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap()
.items[0]
.id,
"acct_oauth_proxy"
);
assert_eq!(
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
.fetch_one(&database.pool)
.await
.unwrap(),
1
);
database.close().await;
}
#[tokio::test]
async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
@@ -1884,6 +1992,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![
account("acct_admin_a", "user-admin-a"),
@@ -1912,6 +2021,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![
account("acct_admin_transient", "user-admin-transient"),
@@ -2101,6 +2211,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![account("acct_grouped_import", "user-grouped-import")],
audit: audit("audit_grouped_import", "import", "acct_grouped_import"),
@@ -2135,6 +2246,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![account("acct_reimport_candidate", "user-grouped-import")],
audit: audit("audit_grouped_reimport", "import", "acct_grouped_import"),
@@ -2203,6 +2315,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![account("acct_rotation_quota", "user-rotation-quota")],
audit: audit(
@@ -2345,6 +2458,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: None,
scope: scope.clone(),
accounts: vec![disabled],
audit: audit(
@@ -2398,7 +2512,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
database.close().await;
}
fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
pub(super) fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
NewProviderAccount {
outbound_proxy: None,
id: id.to_owned(),
@@ -2458,7 +2572,7 @@ fn plaintext_credential(marker: &str) -> PlaintextCredential {
)
}
fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent {
pub(super) fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent {
AdminAuditEvent {
id: id.to_owned(),
actor_kind: AdminAuditActorKind::System,
@@ -2604,7 +2718,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() {
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: Some(Some(
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url(
gateway_core::account::OutboundProxy::parse("http://127.0.0.1:18080").unwrap(),
)),
},
@@ -2663,6 +2777,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
let result = repository
.import_provider_accounts(ImportProviderAccounts {
settings: Some(settings.clone()),
outbound_proxy: None,
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
@@ -2693,6 +2808,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
.expect("existing account");
let failed = repository
.import_provider_accounts(ImportProviderAccounts {
outbound_proxy: None,
settings: Some(AccountImportSettings {
group_ids: vec![
AccountGroupId::new("grp_00000000000000000000000000000092")
@@ -0,0 +1,707 @@
use gateway_admin::{
model::{
MutationActor, MutationContext, PageSize,
accounts::{BatchUpdateAccounts, UpdateAccount},
proxies::*,
},
ports::{
proxy::ProxyStore,
store::{AccountStore, AdminStoreErrorKind},
},
};
use gateway_core::account::{AccountWeight, OutboundProxy, ProviderAccountId};
use gateway_store::postgres::{
PgProviderAccountRepository, PgProxyRepository, ProviderAccountRepository,
};
use super::{TestDatabase, admin_account_store, provider_accounts::account};
fn context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: "managed-proxy-test".to_owned(),
}
}
fn success() -> ProxyTestResult {
ProxyTestResult {
success: true,
latency_ms: 10,
exit_ip: Some("203.0.113.5".parse().unwrap()),
message: "Connected".to_owned(),
}
}
fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount {
UpdateAccount {
account_id: account_id.to_owned(),
enabled: true,
concurrency_limit: None,
weight: AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: Some(selection),
}
}
#[tokio::test]
async fn proxy_account_removal_preserves_settings_and_rejects_changed_bindings() {
let Some(database) = TestDatabase::create("proxy_account_remove").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let context = context();
let saved = store
.create(
NewProxy {
name: "解绑测试".to_owned(),
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:17890").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
sqlx::query(
"insert into provider_accounts
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
has_refresh_token, enabled, concurrency_limit, weight, plan_type,
credential_observed_at, created_at, updated_at, outbound_proxy_id, outbound_proxy_url)
values ('acct_remove', 'openai', '解绑账号', 'remove@example.invalid', 'oauth',
'{\"access_token\":\"preserve-test-secret\"}'::jsonb, false, false, 3, 7, 'plus',
now(), now(), now(), $1, $2)",
)
.bind(&saved.id)
.bind(saved.proxy.expose_url())
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
values ('grp_00000000000000000000000000000001', '保留分组', '#123456FF', true, now(), now())",
).execute(&database.pool).await.unwrap();
sqlx::query(
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
values ('grp_00000000000000000000000000000001', 'acct_remove', now())",
)
.execute(&database.pool)
.await
.unwrap();
let account_id = ProviderAccountId::new("acct_remove").unwrap();
let snapshot = "select to_jsonb(a) - 'updated_at' - 'outbound_proxy_id' - 'outbound_proxy_url'
from provider_accounts a where id = 'acct_remove'";
let before: serde_json::Value = sqlx::query_scalar(snapshot)
.fetch_one(&database.pool)
.await
.unwrap();
let revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(
store
.remove_account("proxy_previous", &account_id, &context)
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::Conflict
);
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 1);
let unchanged: (Option<String>, Option<String>) = sqlx::query_as(
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
).fetch_one(&database.pool).await.unwrap();
assert_eq!(
unchanged,
(
Some(saved.id.clone()),
Some(saved.proxy.expose_url().to_owned())
)
);
let current_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(current_revision, revision);
let committed = store
.remove_account(&saved.id, &account_id, &context)
.await
.unwrap();
assert_eq!(committed.get(), u64::try_from(revision + 1).unwrap());
let after: serde_json::Value = sqlx::query_scalar(snapshot)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(after, before);
let direct: (Option<String>, Option<String>) = sqlx::query_as(
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
).fetch_one(&database.pool).await.unwrap();
assert_eq!(direct, (None, None));
let group_count: i64 = sqlx::query_scalar(
"select count(*) from account_group_accounts where provider_account_id = 'acct_remove'",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(group_count, 1);
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 0);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap()
.total,
0
);
assert_eq!(
store
.remove_account(&saved.id, &account_id, &context)
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::Conflict
);
let current_audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(current_audits, audit_count + 1);
let current_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(u64::try_from(current_revision).unwrap(), committed.get());
database.close().await;
}
#[tokio::test]
async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loading_the_catalog() {
let Some(database) = TestDatabase::create("proxy_account_pagination").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let context = context();
let saved = store
.create(
NewProxy {
name: "分页测试".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:17890").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
let empty = store
.create(
NewProxy {
name: "无关联账号".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:17891").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
// 相同名称验证稳定排序;账号仅写入隔离测试数据库,不接触真实账号池。
sqlx::query(
"insert into provider_accounts
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
has_refresh_token, enabled, credential_observed_at, created_at, updated_at,
outbound_proxy_id, outbound_proxy_url)
select 'acct_page_' || lpad(n::text, 4, '0'),
case when n % 2 = 0 then 'xai' else 'openai' end, '共享账号',
'page_' || lpad(n::text, 4, '0') || '@example.invalid', 'oauth',
'{\"access_token\":\"page-test-secret\"}'::jsonb, false, n % 2 = 0,
now(), now(), now(), $1, $2
from generate_series(1, 1005) as n",
)
.bind(&saved.id)
.bind(saved.proxy.expose_url())
.execute(&database.pool)
.await
.unwrap();
sqlx::query("update provider_accounts set plan_type = 'plus' where id = 'acct_page_0001'")
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
values ('grp_00000000000000000000000000000001', '工作组', '#123456FF', false, now(), now()),
('grp_00000000000000000000000000000002', '次页分组', '#654321FF', true, now(), now())",
).execute(&database.pool).await.unwrap();
sqlx::query(
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
values ('grp_00000000000000000000000000000001', 'acct_page_0001', now()),
('grp_00000000000000000000000000000002', 'acct_page_0021', now())",
)
.execute(&database.pool)
.await
.unwrap();
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account("acct_unrelated", "unrelated-user"))
.await
.unwrap();
let query = |page, search: &str| ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page,
page_size: PageSize::new(20).unwrap(),
search: search.to_owned(),
};
let first = store.list_accounts(query(1, "")).await.unwrap();
assert_eq!((first.total, first.items.len()), (1005, 20));
assert_eq!(first.items[0].id, "acct_page_0001");
assert_eq!(
first.items[0].email.as_deref(),
Some("page_0001@example.invalid")
);
assert_eq!(first.items[0].provider_kind, "openai");
assert_eq!(first.items[0].authentication_kind, "oauth");
assert_eq!(first.items[0].plan_type.as_deref(), Some("plus"));
assert_eq!(first.items[0].groups.len(), 1);
assert_eq!(first.items[0].groups[0].name, "工作组");
assert_eq!(first.items[0].groups[0].color.as_str(), "#123456FF");
assert!(!first.items[0].groups[0].enabled);
assert!(first.items[1].groups.is_empty());
assert!(!first.items[0].enabled);
let second = store.list_accounts(query(2, "")).await.unwrap();
assert_eq!(second.items[0].id, "acct_page_0021");
assert_eq!(second.items[0].groups[0].name, "次页分组");
assert!(
first
.items
.iter()
.all(|left| second.items.iter().all(|right| left.id != right.id))
);
let last = store.list_accounts(query(51, "")).await.unwrap();
assert_eq!((last.total, last.items.len()), (1005, 5));
assert_eq!(last.items[4].id, "acct_page_1005");
let filtered = store.list_accounts(query(1, "PAGE_004")).await.unwrap();
assert_eq!((filtered.total, filtered.items.len()), (10, 10));
assert_eq!(filtered.items[0].id, "acct_page_0040");
assert_eq!(store.list_accounts(query(1, "%")).await.unwrap().total, 0);
assert!(
store
.list_accounts(query(52, ""))
.await
.unwrap()
.items
.is_empty()
);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: empty.id,
..query(1, "")
})
.await
.unwrap()
.total,
0
);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: "missing".to_owned(),
..query(1, "")
})
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::NotFound
);
let catalog = store
.list(ProxyListQuery {
page: 1,
page_size: PageSize::new(20).unwrap(),
search: "分页测试".to_owned(),
})
.await
.unwrap();
assert_eq!(catalog.items[0].account_count, 1005);
database.close().await;
}
#[tokio::test]
async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() {
use gateway_store::postgres::{
ImportProviderAccounts, ProviderAccountAdminRepository, ProviderAccountAdminScope,
};
let Some(database) = TestDatabase::create("proxy_import_reservation").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let other_process = PgProxyRepository::new(database.pool.clone());
let context = context();
let saved = store
.create(
NewProxy {
name: "导入出口".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
assert!(store.reserve_import(&saved.id).await.is_err());
store
.record_test(&saved.id, saved.revision, success(), &context)
.await
.unwrap();
let reservation = store.reserve_import(&saved.id).await.unwrap();
let replacement = UpdateProxy {
id: saved.id.clone(),
revision: saved.revision,
name: saved.name,
proxy: Some(OutboundProxy::parse("http://127.0.0.1:9090").unwrap()),
};
assert!(
other_process
.update(replacement.clone(), &context)
.await
.is_err()
);
assert!(
other_process
.delete(&saved.id, saved.revision, &context)
.await
.is_err()
);
assert!(
other_process
.record_test(
&saved.id,
saved.revision,
ProxyTestResult {
success: false,
..success()
},
&context
)
.await
.is_err()
);
// 模拟上游已轮换的新凭据;持有保护时,另一个连接仍能完成导入事务。
let mut candidate = account("acct_reserved_import", "reserved-import-user");
candidate.outbound_proxy = Some(reservation.binding.proxy.clone());
let repository = PgProviderAccountRepository::new(database.pool.clone());
let audit =
super::provider_accounts::audit("audit_reserved_import", "import", "acct_reserved_import");
tokio::time::timeout(
std::time::Duration::from_secs(5),
repository.import_provider_accounts(ImportProviderAccounts {
settings: None,
outbound_proxy: Some(reservation.binding.clone()),
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![candidate],
audit,
}),
)
.await
.unwrap()
.unwrap();
drop(reservation);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if other_process
.update(replacement.clone(), &context)
.await
.is_ok()
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(
repository
.load_provider_account("acct_reserved_import")
.await
.unwrap()
.is_some()
);
database.close().await;
}
#[tokio::test]
async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_tests() {
let Some(database) = TestDatabase::create("managed_proxies").await else {
return;
};
let accounts = PgProviderAccountRepository::new(database.pool.clone());
for id in ["acct_one", "acct_two"] {
accounts
.insert_provider_account(account(id, id))
.await
.unwrap();
}
let store = PgProxyRepository::new(database.pool.clone());
let admin = admin_account_store(&database.pool);
let context = context();
let old_proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap();
let created = store
.create(
NewProxy {
name: "Office".to_owned(),
proxy: old_proxy.clone(),
},
&context,
)
.await
.unwrap()
.record;
assert_eq!(store.get(&created.id).await.unwrap().proxy, old_proxy);
assert!(
store
.create(
NewProxy {
name: "Duplicate".to_owned(),
proxy: old_proxy.clone()
},
&context
)
.await
.is_err()
);
let selection = AccountProxySelection::Saved(created.id.clone());
assert!(
admin
.update_account(update("acct_one", selection.clone()), &context)
.await
.is_err()
);
let read = || async {
sqlx::query_as::<_, (String, Option<String>, Option<String>, i64)>("select id, outbound_proxy_id, outbound_proxy_url, credential_revision from provider_accounts order by id")
.fetch_all(&database.pool).await.unwrap()
};
assert!(
read()
.await
.iter()
.all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1)
);
let tested = store
.record_test(&created.id, created.revision, success(), &context)
.await
.unwrap();
assert!(tested.last_test_at.is_some());
for id in ["acct_one", "acct_two"] {
admin
.update_account(update(id, selection.clone()), &context)
.await
.unwrap();
}
assert_eq!(store.get(&created.id).await.unwrap().account_count, 2);
assert_eq!(
store
.delete(&created.id, created.revision, &context)
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::Conflict
);
assert!(
read()
.await
.iter()
.all(|row| row.1.as_deref() == Some(created.id.as_str())
&& row.2.as_deref() == Some(old_proxy.expose_url())
&& row.3 == 1)
);
let renamed = store
.update(
UpdateProxy {
id: created.id.clone(),
revision: created.revision,
name: "Renamed".to_owned(),
proxy: None,
},
&context,
)
.await
.unwrap()
.record;
assert_eq!(renamed.proxy, old_proxy);
assert_eq!(renamed.last_test, Some(success()));
assert!(read().await.iter().all(|row| row.3 == 1));
let new_proxy = OutboundProxy::parse("socks5h://next:new-secret@127.0.0.1:1080").unwrap();
let edited = store
.update(
UpdateProxy {
id: created.id.clone(),
revision: renamed.revision,
name: renamed.name,
proxy: Some(new_proxy.clone()),
},
&context,
)
.await
.unwrap()
.record;
assert!(edited.last_test.is_none());
assert!(edited.last_test_at.is_none());
assert!(
read()
.await
.iter()
.all(|row| row.2.as_deref() == Some(new_proxy.expose_url()) && row.3 == 1)
);
assert!(
store
.record_test(&created.id, created.revision, success(), &context)
.await
.is_err()
);
assert!(store.get(&created.id).await.unwrap().last_test.is_none());
store
.record_test(&created.id, edited.revision, success(), &context)
.await
.unwrap();
for id in ["acct_one", "acct_two"] {
admin
.update_account(update(id, AccountProxySelection::Direct), &context)
.await
.unwrap();
}
assert!(
read()
.await
.iter()
.all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1)
);
store
.delete(&created.id, edited.revision, &context)
.await
.unwrap();
assert!(store.get(&created.id).await.is_err());
let audits: Vec<serde_json::Value> =
sqlx::query_scalar("select to_jsonb(a) from admin_audit_events a")
.fetch_all(&database.pool)
.await
.unwrap();
assert!(!serde_json::to_string(&audits).unwrap().contains("secret"));
database.close().await;
}
#[tokio::test]
async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() {
let Some(database) = TestDatabase::create("proxy_catalog_legacy").await else {
return;
};
let accounts = PgProviderAccountRepository::new(database.pool.clone());
let proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap();
for id in ["acct_one", "acct_two"] {
let mut seed = account(id, id);
seed.outbound_proxy = Some(proxy.clone());
accounts.insert_provider_account(seed).await.unwrap();
}
let store = PgProxyRepository::new(database.pool.clone());
let page = store
.list(ProxyListQuery {
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].account_count, 2);
let admin = admin_account_store(&database.pool);
assert!(
admin
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec!["acct_one".to_owned(), "acct_missing".to_owned()],
enabled: false,
concurrency_limit: None,
weight: AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: Some(AccountProxySelection::Url(
OutboundProxy::parse("http://127.0.0.1:9090").unwrap()
)),
},
&context()
)
.await
.is_err()
);
assert_eq!(
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
.fetch_one(&database.pool)
.await
.unwrap(),
1
);
assert_eq!(
sqlx::query_as::<_, (bool, String)>(
"select enabled, outbound_proxy_url from provider_accounts where id = 'acct_one'"
)
.fetch_one(&database.pool)
.await
.unwrap(),
(true, proxy.expose_url().to_owned())
);
database.close().await;
}
#[tokio::test]
async fn migration_backfills_shared_proxies_without_changing_credentials() {
let Some(database) = TestDatabase::create("proxy_backfill").await else {
return;
};
let accounts = PgProviderAccountRepository::new(database.pool.clone());
for id in ["acct_one", "acct_two", "acct_direct"] {
accounts
.insert_provider_account(account(id, id))
.await
.unwrap();
}
sqlx::raw_sql("alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies;
update provider_accounts set outbound_proxy_url = 'http://user:secret@127.0.0.1:8080/' where id <> 'acct_direct';")
.execute(&database.pool).await.unwrap();
sqlx::raw_sql(include_str!(
"../../../../migrations/0005_managed_outbound_proxies.sql"
))
.execute(&database.pool)
.await
.unwrap();
assert_eq!(
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
.fetch_one(&database.pool)
.await
.unwrap(),
1
);
assert_eq!(sqlx::query_scalar::<_, i64>("select count(*) from provider_accounts a join outbound_proxies p on a.outbound_proxy_id = p.id where a.outbound_proxy_url = p.proxy_url and a.credential_revision = 1").fetch_one(&database.pool).await.unwrap(), 2);
assert!(
sqlx::query_scalar::<_, Option<String>>(
"select outbound_proxy_id from provider_accounts where id = 'acct_direct'"
)
.fetch_one(&database.pool)
.await
.unwrap()
.is_none()
);
database.close().await;
}
+4 -3
View File
@@ -291,9 +291,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
) -> Result<PreparedCredentialImport, ProviderAdminError> {
let prepared = self
.credentials
.prepare_import_document(Value::Object(
command.document.into_provider_data().into_inner(),
))
.prepare_import_document_with_proxy(
Value::Object(command.document.into_provider_data().into_inner()),
command.default_outbound_proxy.as_ref(),
)
.await
.inspect_err(|error| {
log_import_failure("prepare_document", credential_admin_error_code(error));
@@ -824,6 +824,14 @@ impl CodexCredentialAdminService {
pub async fn prepare_import_document(
&self,
payload: Value,
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
self.prepare_import_document_with_proxy(payload, None).await
}
pub async fn prepare_import_document_with_proxy(
&self,
payload: Value,
default_proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
if serde_json::to_vec(&payload)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?
@@ -832,7 +840,7 @@ impl CodexCredentialAdminService {
{
return Err(CodexCredentialAdminError::InvalidInput);
}
let candidates = parse_import_document(&payload)?;
let candidates = parse_import_document(&payload, default_proxy)?;
if candidates.is_empty() || candidates.len() > MAX_BATCH {
return Err(CodexCredentialAdminError::InvalidInput);
}
@@ -1041,6 +1049,7 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
fn parse_import_document(
payload: &Value,
default_proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
let payload = payload
.get("data")
@@ -1052,7 +1061,14 @@ fn parse_import_document(
continue;
}
let mut account = parse_oauth_import_account(value)?;
account.outbound_proxy = import_proxy(payload, value)?;
account.outbound_proxy = if ["outboundProxyUrl", "outbound_proxy_url", "proxy_key"]
.iter()
.any(|field| value.get(field).is_some())
{
import_proxy(payload, value)?
} else {
default_proxy.cloned()
};
accounts.push(account);
}
Ok(accounts)
+1 -1
View File
@@ -36,7 +36,7 @@ pub use provider::{
pub mod credential;
pub mod transport;
pub use transport::tls::ensure_rustls_provider;
pub use transport::tls::{build_reqwest_client_with_custom_ca, ensure_rustls_provider};
pub use transport::{
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage,
encode_generate_request, openai_billing_breakdown,
@@ -820,6 +820,12 @@ pub(super) async fn read_error_response_body(
pub(super) fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest {
let mut request = request.clone();
// 上游通过流式事件执行,下游仍按客户端原来的偏好返回响应。
if !request.stream() {
request
.body_mut()
.insert("stream".to_owned(), Value::Bool(true));
}
project_websocket_client_metadata(&mut request);
request
}
@@ -670,9 +670,10 @@ pub(crate) fn transport_metric_reason(error: &tungstenite::Error) -> &'static st
std::io::ErrorKind::TimedOut => "transport_timeout",
_ => "transport_error",
},
// 未收到关闭握手只能证明连接异常结束,不能据此认定收到 TCP RST。
tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
) => "tcp_reset",
) => "reset_without_closing_handshake",
_ => "transport_error",
}
}
@@ -984,6 +984,7 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
let admin = bundle.admin_provider();
let import_error = admin
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: None,
document: ProviderDocument::new(OpaqueProviderData::new(Map::new())),
})
.await
@@ -1025,6 +1026,7 @@ async fn initialized_provider_reports_a_safe_pat_format_error_before_network_acc
let error = bundle
.admin_provider()
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: None,
document: ProviderDocument::new(OpaqueProviderData::new(Map::from_iter([(
"accessToken".to_owned(),
json!("at-sensitive-token with whitespace"),
@@ -56,6 +56,54 @@ async fn sub2api_import_resolves_distinct_proxy_bindings_and_encodes_credentials
assert!(!format!("{prepared:?}").contains("p:a/ss"));
}
#[tokio::test]
async fn import_default_proxy_preserves_explicit_account_exits() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let default_proxy =
gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap();
for (field, value, expected) in [
(
None,
serde_json::Value::Null,
Some("http://default.example:8080/"),
),
(
Some("outboundProxyUrl"),
serde_json::json!("socks5h://override.example:1080"),
Some("socks5h://override.example:1080"),
),
(Some("outbound_proxy_url"), serde_json::json!(""), None),
(Some("outboundProxyUrl"), serde_json::Value::Null, None),
(Some("proxy_key"), serde_json::Value::Null, None),
] {
let mut account = serde_json::json!({"access_token": test_jwt(serde_json::json!({
"https://api.openai.com/auth": {"chatgpt_user_id": "default-proxy-user"}
}))});
if let Some(field) = field {
account[field] = value;
}
let prepared = service
.prepare_import_document_with_proxy(
serde_json::json!({"data": {"accounts": [account]}}),
Some(&default_proxy),
)
.await
.unwrap();
assert_eq!(
prepared.accounts()[0]
.account
.outbound_proxy()
.map(|proxy| proxy.endpoint())
.as_deref(),
expected
);
}
}
#[tokio::test]
async fn sub2api_invalid_proxy_bindings_are_rejected_before_any_token_refresh() {
let service = CodexCredentialAdminService::new(
@@ -746,7 +794,7 @@ async fn oauth_import_rejects_legacy_bare_token_field() {
#[tokio::test]
async fn pat_import_uses_account_proxy_without_direct_fallback() {
for status in [200, 503] {
for (status, use_default) in [(200, false), (503, false), (200, true), (503, true)] {
let origin = MockServer::start().await;
let proxy = MockServer::start().await;
Mock::given(method("GET"))
@@ -756,11 +804,13 @@ async fn pat_import_uses_account_proxy_without_direct_fallback() {
.expect(1)
.mount(&proxy)
.await;
let default_proxy = gateway_core::account::OutboundProxy::parse(&proxy.uri()).unwrap();
let mut document = serde_json::json!({"accessToken": "at-proxy-token"});
if !use_default {
document["outboundProxyUrl"] = serde_json::json!(proxy.uri());
}
let imported = pat_service(&origin)
.prepare_import_document(serde_json::json!({
"accessToken": "at-proxy-token",
"outboundProxyUrl": proxy.uri(),
}))
.prepare_import_document_with_proxy(document, use_default.then_some(&default_proxy))
.await;
if status == 200 {
let prepared = imported.expect("PAT validated through account proxy");
@@ -1000,7 +1000,7 @@ async fn connection_local_continuation_should_fail_after_its_live_socket_disappe
let observation = error
.connection_observation()
.expect("disappeared connection should retain its lifecycle observation");
assert_eq!(observation.exit_reason(), "tcp_reset");
assert_eq!(observation.exit_reason(), "reset_without_closing_handshake");
assert!(observation.age_ms() >= observation.idle_ms());
}
@@ -193,6 +193,74 @@ fn websocket_connection_should_prepare_response_create_payload_text() {
assert!(payload.get("store").is_none());
}
#[tokio::test]
async fn backend_websocket_should_stream_upstream_for_non_streaming_client_request() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let Some(Ok(Message::Text(payload))) = websocket.next().await else {
panic!("client should send response.create");
};
let payload: Value = serde_json::from_str(payload.as_str()).unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_non_streaming",
"object": "response",
"status": "completed",
"output": [],
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4}
}
})
.to_string()
.into(),
))
.await
.unwrap();
payload
});
let request = CodexResponsesRequest::from_body(
json!({
"model": "gpt-test",
"instructions": "collect this response",
"input": [],
"stream": false,
"previous_response_id": "resp_previous"
})
.as_object()
.unwrap()
.clone(),
);
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{address}"),
test_wire_profile(),
);
let response = timeout(
Duration::from_secs(5),
backend.create_response(
&request,
request_context("req_non_streaming_ws", Some("acct-non-streaming")),
),
)
.await
.unwrap()
.expect("upstream WebSocket response");
let payload = server.await.unwrap();
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
assert!(response.body.contains("resp_non_streaming"));
assert_eq!(payload["stream"], true);
assert_eq!(payload["previous_response_id"], "resp_previous");
assert!(
!request.stream(),
"downstream delivery remains non-streaming"
);
}
#[test]
fn websocket_connection_should_prepare_capture_payload_with_canonical_field_order() {
let mut request = codex_request_with_prompt_cache_key(
+6 -3
View File
@@ -429,8 +429,11 @@ impl ProviderAdmin for XaiAdminProvider {
command.document.into_provider_data().into_inner(),
))
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
let document = GrokOAuthImportDocument::parse_json(&document)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
let document = GrokOAuthImportDocument::parse_json_with_proxy(
&document,
command.default_outbound_proxy.as_ref(),
)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
let mut subjects = BTreeSet::new();
let mut credentials = Vec::new();
// 逐条目独立成败:verify 会真实轮换 RT,后续条目的任何失败都不得
@@ -1140,7 +1143,7 @@ fn project_quota(
}
};
ProviderQuota {
plan_type: None,
plan_type: billing.plan_type().map(str::to_owned),
observed_at: Some(snapshot.observed_at()),
refresh_token_expires_at,
windows: vec![window],
@@ -38,6 +38,7 @@ pub const GROK_FREE_ROLLING_WINDOW_SECONDS: u64 = 86_400;
/// xAI Provider 从动态 billing JSON 解析出的旧账号页安全投影。
#[derive(Debug, Clone, PartialEq)]
pub struct GrokBillingPresentation {
plan_type: Option<String>,
used_percent: Option<f64>,
period_type: Option<String>,
period_start: Option<String>,
@@ -58,6 +59,11 @@ pub enum GrokQuotaPeriodKind {
}
impl GrokBillingPresentation {
#[must_use]
pub fn plan_type(&self) -> Option<&str> {
self.plan_type.as_deref()
}
#[must_use]
pub const fn used_percent(&self) -> Option<f64> {
self.used_percent
@@ -455,8 +461,19 @@ impl GrokCredentialQuotaService {
.fetch(&session)
.await
.map_err(|_| GrokQuotaError::Upstream)?;
let mut document = billing.into_document();
// 订阅查询失败不影响已取得的额度,也不能据此写入免费套餐。
match self.client.fetch_subscription(&session).await {
Ok(Some(plan_type)) => {
document.insert(
"subscriptionTier".to_owned(),
serde_json::Value::String(plan_type),
);
}
Ok(None) => {}
Err(error) => tracing::warn!(%error, "xAI subscription query failed"),
}
let observed_at = Utc::now();
let document = billing.into_document();
let presentation = billing_presentation(&document)?;
let quota = loaded
.account
@@ -1243,6 +1260,7 @@ fn billing_presentation(
_ => None,
});
Ok(GrokBillingPresentation {
plan_type: dynamic_string(Some(snapshot.document()), "subscriptionTier"),
used_percent,
period_type: dynamic_string(current_period, "type"),
period_start: dynamic_string(current_period, "start")
@@ -162,6 +162,13 @@ impl GrokOAuthImportDocument {
/// 独立代理 URL 随账号导入;其他展示 metadata 不参与认证。
/// 实际 token 仍须通过官方 refresh/user-info 验证;API Key 不能混入 OAuth 条目。
pub fn parse_json(document: &[u8]) -> Result<Self, GrokOAuthImportError> {
Self::parse_json_with_proxy(document, None)
}
pub fn parse_json_with_proxy(
document: &[u8],
default_proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Self, GrokOAuthImportError> {
if document.is_empty() || document.len() > MAX_IMPORT_DOCUMENT_BYTES {
return Err(GrokOAuthImportError::InvalidField("document"));
}
@@ -174,7 +181,13 @@ impl GrokOAuthImportDocument {
let now = Utc::now();
let mut entries = Vec::with_capacity(accounts.len());
for (index, account) in accounts.into_iter().enumerate() {
if let Some(entry) = parse_account_entry(account, index, now)? {
if let Some(mut entry) = parse_account_entry(account, index, now)? {
if !["outboundProxyUrl", "outbound_proxy_url", "proxy_key"]
.iter()
.any(|field| account.get(field).is_some())
{
entry.outbound_proxy = default_proxy.cloned();
}
entries.push(entry);
}
}
+25 -24
View File
@@ -56,30 +56,31 @@ pub use transport::profile::{
GrokCliReleaseStatus, GrokCliReleaseTransport,
};
pub use transport::{
GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient,
GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport,
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
GrokBillingTransportResponse, GrokCanonicalDecoder, GrokCatalogApiBackend,
GrokCatalogCapabilities, GrokCatalogCapabilityEvidence, GrokCatalogLimits, GrokCatalogMetadata,
GrokCatalogModel, GrokCatalogReasoningEffort, GrokClientIdentity, GrokCompactionDecodeError,
GrokCompactionRequest, GrokCompactionSummaryDecoder, GrokCredentialFailure,
GrokCredentialFeedbackFuture, GrokDnsResolutionError, GrokDnsResolutionPlan,
GrokDnsResolutionPolicy, GrokEndpointPolicy, GrokHeader, GrokHeaderValue,
GrokInferenceChunkStream, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokInferenceTransportMetrics, GrokModelCatalogClient, GrokModelCatalogError,
GrokModelCatalogRequest, GrokModelCatalogSession, GrokModelCatalogSessionError,
GrokModelCatalogSnapshot, GrokModelCatalogTransport, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, GrokProviderConfigError, GrokProviderTransport,
GrokRequestEncodeError, GrokReqwestTransportBuildError, GrokResponsesRequest,
GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError, GrokSessionLeaseGuard,
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OfficialGrokEndpointPolicy,
ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport,
SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers, grok_billing_breakdown,
grok_billing_breakdown_with_tier, parse_grok_billing, parse_grok_model_catalog,
GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GROK_SUBSCRIPTION_URL,
GrokBillingClient, GrokBillingError, GrokBillingRequest, GrokBillingSnapshot,
GrokBillingTransport, GrokBillingTransportError, GrokBillingTransportErrorKind,
GrokBillingTransportFuture, GrokBillingTransportResponse, GrokCanonicalDecoder,
GrokCatalogApiBackend, GrokCatalogCapabilities, GrokCatalogCapabilityEvidence,
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokCatalogReasoningEffort,
GrokClientIdentity, GrokCompactionDecodeError, GrokCompactionRequest,
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokCredentialFeedbackFuture,
GrokDnsResolutionError, GrokDnsResolutionPlan, GrokDnsResolutionPolicy, GrokEndpointPolicy,
GrokHeader, GrokHeaderValue, GrokInferenceChunkStream, GrokInferenceClientCacheStatus,
GrokInferenceDnsObservation, GrokInferenceDnsSource, GrokInferenceRequest,
GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError,
GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokInferenceTransportMetrics,
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokProviderConfigError,
GrokProviderTransport, GrokRequestEncodeError, GrokReqwestTransportBuildError,
GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError,
GrokSessionLeaseGuard, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError,
GrokSessionSelectorFuture, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES,
OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport,
ReqwestOAuthTransport, SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers,
grok_billing_breakdown, grok_billing_breakdown_with_tier, parse_grok_billing,
parse_grok_model_catalog,
};
/// xAI 初始化后交给组装根的最小能力集。
@@ -7,6 +7,8 @@ pub(super) struct XaiSessionState {
pub(super) account_id: String,
pub(super) session_id: Option<String>,
pub(super) transcript: Vec<XaiReplayItem>,
pub(super) instructions: Option<Value>,
pub(super) response_stored: bool,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -20,6 +22,8 @@ pub(super) enum XaiReplayItem {
pub(super) struct GrokSessionCapture {
pub(super) previous: Option<XaiSessionState>,
pub(super) request_input: Vec<Value>,
pub(super) instructions: Option<Value>,
pub(super) response_stored: bool,
pub(super) account_id: String,
pub(super) session_id: Option<String>,
pub(super) output_items: BTreeMap<u32, Value>,
@@ -110,6 +114,15 @@ pub(super) fn apply_continuation(
if !pin.matches(&provider, account) {
return Err(invalid_continuation());
}
if previous_session.is_some_and(|state| !state.response_stored)
|| request.instructions()
!= previous_session.and_then(|state| state.instructions.as_ref())
{
let previous = previous_session.ok_or_else(invalid_continuation)?;
return apply_replay(request, previous, account, current_input);
}
// Grok 原生续接继承首轮指令,不允许同时提交 instructions。
request.clear_instructions();
request.set_previous_response_id(Some(pin.upstream_response_id().as_str().to_owned()));
Ok(())
}
@@ -120,18 +133,27 @@ pub(super) fn apply_continuation(
{
return Err(invalid_continuation());
}
let mut input = replay_input_for_account(previous, account.as_str(), true);
input.reserve(current_input.len());
input.extend(current_input.iter().cloned());
request.set_replay_input(input).map_err(map_request_error)?;
request.set_previous_response_id(None);
request.inherit_session(None);
Ok(())
apply_replay(request, previous, account, current_input)
}
ContinuationAttempt::None => Err(invalid_continuation()),
}
}
fn apply_replay(
request: &mut GrokResponsesRequest,
previous: &XaiSessionState,
account: &gateway_core::account::ProviderAccountId,
current_input: &[Value],
) -> Result<(), ProviderError> {
let mut input = replay_input_for_account(previous, account.as_str(), true);
input.reserve(current_input.len());
input.extend(current_input.iter().cloned());
request.set_replay_input(input).map_err(map_request_error)?;
request.set_previous_response_id(None);
request.inherit_session(None);
Ok(())
}
pub(super) fn replay_input_for_account(
state: &XaiSessionState,
account_id: &str,
@@ -261,6 +283,8 @@ pub(super) fn attach_xai_session_update(
account_id: capture.account_id,
session_id: capture.session_id,
transcript,
instructions: capture.instructions,
response_stored: capture.response_stored,
};
if let Some(update) = encode_xai_session_state(state)? {
events[terminal_index].attach_session_update(update);
@@ -230,6 +230,7 @@ impl GrokBuildProvider {
)
.map_err(|_| protocol_not_sent())?;
let request_input = upstream_request.input_items();
let instructions = upstream_request.instructions().cloned();
if let Some(previous) = previous_session.as_ref() {
upstream_request.inherit_session(previous.session_id.as_deref());
}
@@ -293,6 +294,12 @@ impl GrokBuildProvider {
.then(|| GrokSessionCapture {
previous: previous_session,
request_input,
instructions,
response_stored: upstream_request
.body()
.get("store")
.and_then(Value::as_bool)
== Some(true),
account_id: selected.account_id().as_str().to_owned(),
session_id: upstream_request.session_id().map(str::to_owned),
output_items: BTreeMap::new(),
@@ -294,10 +294,10 @@ impl GrokCanonicalDecoder {
continue;
}
let Some(event_type) = body_type.or(event.event.as_deref()) else {
let Ok(transformed) = self.response_transform.rewrite_stream_event("", value)
else {
continue;
};
let transformed = self
.response_transform
.rewrite_stream_event("", value)
.map_err(|_| protocol_error_marker())?;
for (index, transformed) in transformed.into_iter().enumerate() {
let mut value = transformed.into_value();
self.response_transform.resequence_stream_value(&mut value);
@@ -324,13 +324,11 @@ impl GrokCanonicalDecoder {
if let Some(item) = value.get("item") {
self.requires_provider_cost |= !token_only_output(item);
}
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
let Ok(transformed) = self
// 转换失败必须终止,不能丢弃工具参数后仍向客户端报告成功。
let transformed = self
.response_transform
.rewrite_stream_event(&event_type, value)
else {
continue;
};
.map_err(|_| protocol_error_marker())?;
for (index, transformed) in transformed.into_iter().enumerate() {
let transformed_type = transformed.event_type().to_owned();
if !client_visible_event(&transformed_type) {
@@ -19,6 +19,9 @@ use crate::{GrokHeader, SecretValue, XaiWireProfileState};
pub const GROK_MODEL_CATALOG_URL: &str = "https://cli-chat-proxy.grok.com/v1/models";
/// 官方 Grok Build credits/billing URL。
pub const GROK_BILLING_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
/// 官方实时订阅查询 URL;额度响应本身不提供套餐事实。
pub const GROK_SUBSCRIPTION_URL: &str =
"https://cli-chat-proxy.grok.com/v1/user?include=subscription";
/// 单次 Grok 模型目录响应允许的最大字节数。
pub const MAX_GROK_MODEL_CATALOG_BYTES: usize = 1024 * 1024;
/// 单次 Grok billing 响应允许的最大字节数。
@@ -28,6 +31,7 @@ pub(crate) const MAX_CATALOG_MODELS: usize = 2_048;
const MAX_DISPLAY_NAME_BYTES: usize = 256;
const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
const MAX_ETAG_BYTES: usize = 256;
const SUBSCRIPTION_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// 构造模型目录 OAuth session 失败。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
@@ -255,7 +259,7 @@ pub trait GrokModelCatalogTransport: Send + Sync {
fn execute(&self, request: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_>;
}
/// 交给单次官方 billing GET transport 的完整请求。
/// 交给单次官方计费或订阅 GET transport 的完整请求。
#[derive(Debug)]
pub struct GrokBillingRequest {
endpoint: Url,
@@ -265,8 +269,14 @@ pub struct GrokBillingRequest {
impl GrokBillingRequest {
fn from_session(session: &GrokModelCatalogSession) -> Result<Self, GrokBillingError> {
let endpoint =
Url::parse(GROK_BILLING_URL).map_err(|_| GrokBillingError::InvalidRequest)?;
Self::for_endpoint(session, GROK_BILLING_URL)
}
fn for_endpoint(
session: &GrokModelCatalogSession,
endpoint: &str,
) -> Result<Self, GrokBillingError> {
let endpoint = Url::parse(endpoint).map_err(|_| GrokBillingError::InvalidRequest)?;
let mut headers = vec![
GrokHeader::sensitive(
"authorization",
@@ -291,7 +301,7 @@ impl GrokBillingRequest {
})
}
/// 返回固定官方 `/v1/billing?format=credits` URL。
/// 返回固定官方计费或订阅 URL。
#[must_use]
pub const fn endpoint(&self) -> &Url {
&self.endpoint
@@ -456,6 +466,54 @@ impl GrokBillingClient {
.map_err(|_| GrokBillingError::Transport)?;
parse_grok_billing(response.body())
}
/// 查询实时订阅,只返回套餐事实,不保存用户资料。
pub async fn fetch_subscription(
&self,
session: &GrokModelCatalogSession,
) -> Result<Option<String>, GrokBillingError> {
let request = GrokBillingRequest::for_endpoint(session, GROK_SUBSCRIPTION_URL)?;
let response = tokio::time::timeout(
SUBSCRIPTION_REQUEST_TIMEOUT,
self.transport.execute(request),
)
.await
.map_err(|_| GrokBillingError::Transport)?
.map_err(|_| GrokBillingError::Transport)?;
parse_subscription_tier(response.body())
}
}
fn parse_subscription_tier(body: &[u8]) -> Result<Option<String>, GrokBillingError> {
if body.len() > MAX_GROK_BILLING_BYTES {
return Err(GrokBillingError::ResponseTooLarge);
}
let document: Map<String, Value> =
serde_json::from_slice(body).map_err(|_| GrokBillingError::InvalidWire)?;
let user_id = document
.get("userId")
.ok_or(GrokBillingError::InvalidWire)?;
validate_dynamic_text(user_id, 1_024)?;
if user_id.as_str().is_none_or(|value| value.trim().is_empty()) {
return Err(GrokBillingError::InvalidWire);
}
match document.get("subscriptionTier") {
Some(value @ Value::String(tier)) => {
validate_dynamic_text(value, 512)?;
let tier = tier.trim();
Ok((!tier.is_empty()).then(|| tier.to_owned()))
}
// 官方仅在存在有效付费订阅时返回套餐名;缺字段或团队身份不能推断为 Free。
Some(Value::Null)
if document.get("principalType").and_then(Value::as_str) == Some("User")
&& document.get("teamId") == Some(&Value::Null)
&& document.get("organizationId") == Some(&Value::Null) =>
{
Ok(Some("Free".to_owned()))
}
None | Some(Value::Null) => Ok(None),
_ => Err(GrokBillingError::InvalidWire),
}
}
impl fmt::Debug for GrokBillingClient {
@@ -15,16 +15,17 @@ pub use canonical::{
GrokCanonicalDecoder, grok_billing_breakdown, grok_billing_breakdown_with_tier,
};
pub use catalog::{
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError,
GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, GrokBillingTransportError,
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
GrokCatalogApiBackend, GrokCatalogCapabilities, GrokCatalogCapabilityEvidence,
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokCatalogReasoningEffort,
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, MAX_GROK_BILLING_BYTES,
MAX_GROK_MODEL_CATALOG_BYTES, parse_grok_billing, parse_grok_model_catalog,
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GROK_SUBSCRIPTION_URL, GrokBillingClient,
GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport,
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
GrokBillingTransportResponse, GrokCatalogApiBackend, GrokCatalogCapabilities,
GrokCatalogCapabilityEvidence, GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel,
GrokCatalogReasoningEffort, GrokModelCatalogClient, GrokModelCatalogError,
GrokModelCatalogRequest, GrokModelCatalogSession, GrokModelCatalogSessionError,
GrokModelCatalogSnapshot, GrokModelCatalogTransport, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES,
parse_grok_billing, parse_grok_model_catalog,
};
pub use compaction::{
GrokCompactionDecodeError, GrokCompactionRequest, GrokCompactionSummaryDecoder,
@@ -767,7 +767,6 @@ fn valid_billing_url(url: &Url, host: &str) -> bool {
url.scheme() == "https"
&& url.host_str() == Some(host)
&& url.port_or_known_default() == Some(443)
&& url.path() == OFFICIAL_BILLING_PATH
&& url.username().is_empty()
&& url.password().is_none()
&& url.fragment().is_none()
@@ -775,7 +774,11 @@ fn valid_billing_url(url: &Url, host: &str) -> bool {
&& url
.query_pairs()
.next()
.is_some_and(|(key, value)| key == "format" && value == "credits")
.is_some_and(|(key, value)| match url.path() {
OFFICIAL_BILLING_PATH => key == "format" && value == "credits",
"/v1/user" => key == "include" && value == "subscription",
_ => false,
})
}
#[derive(Debug)]
@@ -40,27 +40,7 @@ pub(super) fn normalize_compaction_input(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let has_structured_compaction_shape = item.contains_key("summary")
|| item.contains_key("id")
|| item.contains_key("status")
|| string_field(item, "type").trim() == "compaction_summary";
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
// xAI reasoning 密文 + 可见 conversation summary。
if !has_structured_compaction_shape {
let Some(summary) = encrypted else {
return Ok(Vec::new());
};
let continuation = format!(
"This session is being continued from a previous conversation that ran out of context. \
The summary below covers the earlier portion of the conversation.\n\n{summary}"
);
return Ok(vec![Value::Object(compaction_summary_message(
&continuation,
))]);
}
// 密文只作为 reasoning 回放;可见摘要只读取 summary。
let mut converted = Vec::with_capacity(2);
if let Some(encrypted) = encrypted {
converted.push(json!({
@@ -339,139 +319,6 @@ pub(super) fn normalize_apply_patch_output_input(
]))
}
pub(super) fn normalize_legacy_local_shell_call_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let call_id = required_trimmed_string(item, "call_id")?;
let action = legacy_shell_action(item.get("action"))?;
let mut converted = Map::from_iter([
("type".to_owned(), Value::String("shell_call".to_owned())),
("call_id".to_owned(), Value::String(call_id.to_owned())),
("action".to_owned(), Value::Object(action)),
]);
for key in ["id", "status", "timeout_ms", "max_output_length"] {
if let Some(value) = item.get(key) {
converted.insert(key.to_owned(), value.clone());
}
}
Ok(converted)
}
pub(super) fn legacy_shell_action(
value: Option<&Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let action = value
.and_then(Value::as_object)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
if !matches!(string_field(action, "type").trim(), "" | "exec") {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let command = legacy_shell_command(action)?;
Ok(Map::from_iter([
("type".to_owned(), Value::String("exec".to_owned())),
(
"commands".to_owned(),
Value::Array(vec![Value::String(command)]),
),
]))
}
pub(super) fn legacy_shell_command(
action: &Map<String, Value>,
) -> Result<String, GrokRequestEncodeError> {
let mut command = match action.get("command") {
Some(Value::String(value)) => value.trim().to_owned(),
Some(Value::Array(parts)) => parts
.iter()
.map(|part| {
part.as_str()
.map(quote_shell_argument)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
})
.collect::<Result<Vec<_>, _>>()?
.join(" "),
_ => action
.get("commands")
.and_then(Value::as_array)
.map(|commands| {
commands
.iter()
.map(|command| {
command
.as_str()
.map(ToOwned::to_owned)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
})
.collect::<Result<Vec<_>, _>>()
.map(|commands| commands.join("\n"))
})
.transpose()?
.unwrap_or_default(),
};
if command.is_empty() {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
if let Some(environment) = action.get("env").and_then(Value::as_object)
&& !environment.is_empty()
{
let assignments = environment
.iter()
.map(|(name, value)| {
if !valid_environment_name(name) {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let value = value
.as_str()
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
Ok(format!("{name}={}", quote_shell_argument(value)))
})
.collect::<Result<Vec<_>, _>>()?;
command = format!("env {} {command}", assignments.join(" "));
}
if let Some(directory) = action
.get("working_directory")
.and_then(Value::as_str)
.map(str::trim)
.filter(|directory| !directory.is_empty())
{
command = format!("cd {} && {command}", quote_shell_argument(directory));
}
Ok(command)
}
pub(super) fn normalize_legacy_local_shell_output_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let call_id = required_trimmed_string(item, "call_id")?;
let output = match item.get("output") {
Some(Value::Array(output)) => output.clone(),
Some(Value::String(output)) => {
let exit_code = item
.get("exit_code")
.and_then(Value::as_i64)
.unwrap_or_else(|| {
i64::from(string_field(item, "status").eq_ignore_ascii_case("failed"))
});
vec![shell_output_block(output, "", "exit", Some(exit_code))]
}
_ => return Err(GrokRequestEncodeError::InvalidRequestNormalization),
};
let mut converted = Map::from_iter([
(
"type".to_owned(),
Value::String("shell_call_output".to_owned()),
),
("call_id".to_owned(), Value::String(call_id.to_owned())),
("output".to_owned(), Value::Array(output)),
]);
if let Some(value) = item.get("max_output_length")
&& !value.is_null()
{
converted.insert("max_output_length".to_owned(), value.clone());
}
Ok(converted)
}
pub(super) fn normalize_shell_call_output_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
@@ -555,24 +402,3 @@ pub(super) fn shell_output_block(
("outcome", Value::Object(outcome)),
])
}
pub(super) fn quote_shell_argument(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_@%+=:,./-".contains(character))
{
return value.to_owned();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(super) fn valid_environment_name(value: &str) -> bool {
value.chars().enumerate().all(|(index, character)| {
character.is_ascii_alphabetic()
|| character == '_'
|| (index > 0 && character.is_ascii_digit())
}) && !value.is_empty()
}
@@ -0,0 +1,332 @@
//! 会话身份、租户隔离与缓存路由。
use super::*;
const IDENTITY_FIELDS: &[&str] = &[
"Authorization",
"authorization",
"Cookie",
"cookie",
"accessToken",
"access_token",
"accountId",
"account_id",
"cookies",
"email",
"idToken",
"id_token",
"refreshToken",
"refresh_token",
"sessionToken",
"session_token",
"teamId",
"team_id",
"token",
"userId",
"user_id",
"x-email",
"x-grok-user-id",
"x-userid",
];
const ACCOUNT_BOUND_FIELDS: &[&str] = &[
"agentId",
"agent_id",
"conversation",
"conversationId",
"conversation_id",
"previousResponseId",
"previous_response_id",
"responseId",
"response_id",
"sessionId",
"session_id",
"x-grok-agent-id",
"x-grok-conv-id",
"x-grok-session-id",
];
const SESSION_FIELDS: &[&str] = &[
"prompt_cache_key",
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
const PROTOCOL_CONTEXT_SESSION_FIELDS: &[&str] = &["conversation_id", "session_id", "thread_id"];
const MAX_SESSION_SEED_BYTES: usize = 1_024;
const GROK_CACHE_ROUTE_TOOLS: &[&str] = &["web_search", "x_search"];
pub(super) fn sanitize_account_identity(body: &mut Map<String, Value>) {
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
body.remove(*field);
}
}
pub(super) fn sanitize_client_metadata(body: &mut Map<String, Value>) {
// Codex 的 client_metadata 是本地 transport envelope,可能包含工作目录、仓库地址、
// installation/session 标识。会话亲和信息已在调用本函数前提取,整个 envelope 都不能
// 越过 Grok Build 边界。
body.remove("client_metadata");
body.remove("metadata");
}
pub(super) fn enable_grok_prompt_cache_route(
body: &mut Map<String, Value>,
upstream_model: &str,
response: &mut GrokResponseTransform,
) {
let tools = body
.entry("tools".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
let Value::Array(tools) = tools else {
return;
};
response.observe_upstream_cache_tools(tools);
if is_cache_media_model(upstream_model) || has_tool_type(tools, "image_generation") {
return;
}
if tools.is_empty() {
for tool in GROK_CACHE_ROUTE_TOOLS {
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
response.mark_injected_cache_tool(tool);
}
// 无客户端工具时以 none 选中缓存路由,同时不授予搜索能力。
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
} else if !has_tool_type(tools, "x_search") {
tools.push(json_object([(
"type",
Value::String("x_search".to_owned()),
)]));
response.mark_injected_cache_tool("x_search");
}
}
fn is_cache_media_model(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
["image", "imagine", "video"]
.into_iter()
.any(|marker| model.contains(marker))
}
pub(super) fn explicit_session_seed(
request: &GenerateRequest,
body: &Map<String, Value>,
) -> Option<String> {
request
.prompt_cache_key()
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| first_session_value(body))
.or_else(|| {
body.get("metadata")
.and_then(Value::as_object)
.and_then(first_session_value)
})
.or_else(|| {
PROTOCOL_CONTEXT_SESSION_FIELDS.iter().find_map(|field| {
request
.protocol_payload()
.context()
.get(*field)
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
})
}
fn first_session_value(body: &Map<String, Value>) -> Option<String> {
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned);
prompt_cache_key
.or_else(|| {
let metadata = body.get("metadata")?.as_object()?;
["session_id", "sessionId"]
.into_iter()
.find_map(|field| metadata.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| {
metadata
.get("user_id")
.and_then(Value::as_str)
.and_then(session_seed_from_user_id)
})
})
.or_else(|| {
SESSION_FIELDS[1..]
.iter()
.find_map(|field| body.get(*field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
}
fn valid_session_seed(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= MAX_SESSION_SEED_BYTES
&& !value.chars().any(char::is_control))
.then_some(value)
}
fn session_seed_from_user_id(value: &str) -> Option<String> {
let value = value.trim();
if let Ok(Value::Object(embedded)) = serde_json::from_str::<Value>(value)
&& let Some(seed) = ["session_id", "sessionId"]
.into_iter()
.find_map(|field| embedded.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
{
return Some(seed.to_owned());
}
value
.rfind("_session_")
.and_then(|index| valid_session_seed(&value[index + "_session_".len()..]))
.map(ToOwned::to_owned)
}
pub(super) fn resolve_session_identity(
client_api_key_ref: &str,
upstream_model: &str,
explicit_seed: Option<&str>,
body: &Map<String, Value>,
) -> Option<(String, GrokSessionAffinityKey)> {
let model = upstream_model.trim().to_ascii_lowercase();
if client_api_key_ref.is_empty() || model.is_empty() {
return None;
}
if let Some(seed) = explicit_seed {
let upstream_source =
format!("xai:build-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{seed}");
let affinity_source = format!(
"xai:build-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{seed}"
);
return Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
));
}
let (system, first_user) = message_anchors(body);
let first_user = truncate_anchor(&first_user, 200);
if first_user.is_empty() {
return None;
}
let system = truncate_anchor(&system, 100);
let upstream_source = format!(
"xai:build-soft-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{system}:{first_user}"
);
let affinity_source = format!(
"xai:build-soft-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{system}:{first_user}"
);
Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
))
}
fn digest_uuid(source: &str) -> String {
let digest = Sha256::digest(source);
let value = digest[..16]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!(
"{}-{}-{}-{}-{}",
&value[0..8],
&value[8..12],
&value[12..16],
&value[16..20],
&value[20..32]
)
}
fn truncate_anchor(value: &str, max_chars: usize) -> String {
value.trim().chars().take(max_chars).collect()
}
fn message_anchors(body: &Map<String, Value>) -> (String, String) {
let mut system = body
.get("instructions")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| body.get("system").map(flatten_message_content))
.unwrap_or_default();
let mut first_user = String::new();
if let Some(messages) = body.get("messages").and_then(Value::as_array) {
collect_role_anchors(messages, &mut system, &mut first_user);
}
if first_user.is_empty() {
match body.get("input") {
Some(Value::String(value)) => first_user = value.trim().to_owned(),
Some(Value::Array(items)) => {
collect_role_anchors(items, &mut system, &mut first_user);
}
_ => {}
}
}
(system, first_user)
}
fn collect_role_anchors(items: &[Value], system: &mut String, first_user: &mut String) {
for item in items {
let Some(item) = item.as_object() else {
continue;
};
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if !item_type.is_empty() && item_type != "message" {
continue;
}
let role = item
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let content = item
.get("content")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| {
item.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_default();
if content.is_empty() {
continue;
}
match role.as_str() {
"system" | "developer" if system.is_empty() => *system = content,
"user" if first_user.is_empty() => *first_user = content,
"" if first_user.is_empty() => *first_user = content,
_ => {}
}
if !first_user.is_empty() && !system.is_empty() {
break;
}
}
}
fn flatten_message_content(value: &Value) -> String {
match value {
Value::String(value) => value.trim().to_owned(),
Value::Array(parts) => parts
.iter()
.filter_map(Value::as_object)
.filter(|part| {
matches!(
part.get("type").and_then(Value::as_str).unwrap_or_default(),
"" | "text" | "input_text" | "output_text"
)
})
.filter_map(|part| part.get("text").and_then(Value::as_str).map(str::trim))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
@@ -11,67 +11,21 @@ use sha2::{Digest as _, Sha256};
use super::{GrokSessionAffinityKey, XAI_PROVIDER_NAME};
mod history;
mod identity;
mod model;
mod response;
mod schema;
mod tools;
pub(super) use history::strip_invalid_encrypted_reasoning_from_body;
pub(crate) use response::GrokResponseTransform;
use history::*;
use identity::*;
use model::*;
use schema::*;
use tools::*;
const IDENTITY_FIELDS: &[&str] = &[
"Authorization",
"authorization",
"Cookie",
"cookie",
"accessToken",
"access_token",
"accountId",
"account_id",
"cookies",
"email",
"idToken",
"id_token",
"refreshToken",
"refresh_token",
"sessionToken",
"session_token",
"teamId",
"team_id",
"token",
"userId",
"user_id",
"x-email",
"x-grok-user-id",
"x-userid",
];
const ACCOUNT_BOUND_FIELDS: &[&str] = &[
"agentId",
"agent_id",
"conversation",
"conversationId",
"conversation_id",
"previousResponseId",
"previous_response_id",
"responseId",
"response_id",
"sessionId",
"session_id",
"x-grok-agent-id",
"x-grok-conv-id",
"x-grok-session-id",
];
const SESSION_FIELDS: &[&str] = &[
"prompt_cache_key",
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
const PROTOCOL_CONTEXT_SESSION_FIELDS: &[&str] = &["conversation_id", "session_id", "thread_id"];
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] =
@@ -173,6 +127,16 @@ impl GrokResponsesRequest {
strip_invalid_encrypted_reasoning_from_body(&mut self.body)
}
pub(crate) fn instructions(&self) -> Option<&Value> {
self.body
.get("instructions")
.filter(|value| !value.is_null())
}
pub(crate) fn clear_instructions(&mut self) {
self.body.remove("instructions");
}
pub(crate) fn set_previous_response_id(&mut self, response_id: Option<String>) {
match response_id {
Some(response_id) => {
@@ -378,518 +342,18 @@ impl GrokRequestEncodeError {
}
}
fn sanitize_account_identity(body: &mut Map<String, Value>) {
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
body.remove(*field);
}
let Some(Value::Object(metadata)) = body.get_mut("metadata") else {
return;
};
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
metadata.remove(*field);
}
}
fn sanitize_client_metadata(body: &mut Map<String, Value>) {
// Codex 的 client_metadata 是本地 transport envelope,可能包含工作目录、仓库地址、
// installation/session 标识。会话亲和信息已在调用本函数前提取,整个 envelope 都不能
// 越过 Grok Build 边界。
body.remove("client_metadata");
}
fn enable_grok_prompt_cache_route(
body: &mut Map<String, Value>,
upstream_model: &str,
response: &mut GrokResponseTransform,
) {
let tools = body
.entry("tools".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
let Value::Array(tools) = tools else {
return;
};
response.observe_upstream_cache_tools(tools);
if is_cache_media_model(upstream_model) || has_tool_type(tools, "image_generation") {
return;
}
if tools.is_empty() {
for tool in GROK_CACHE_ROUTE_TOOLS {
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
response.mark_injected_cache_tool(tool);
}
// 无客户端工具时以 none 选中缓存路由,同时不授予搜索能力。
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
} else if !has_tool_type(tools, "x_search") {
tools.push(json_object([(
"type",
Value::String("x_search".to_owned()),
)]));
response.mark_injected_cache_tool("x_search");
}
}
fn is_cache_media_model(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
["image", "imagine", "video"]
.into_iter()
.any(|marker| model.contains(marker))
}
fn explicit_session_seed(request: &GenerateRequest, body: &Map<String, Value>) -> Option<String> {
request
.prompt_cache_key()
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| first_session_value(body))
.or_else(|| {
body.get("metadata")
.and_then(Value::as_object)
.and_then(first_session_value)
})
.or_else(|| {
PROTOCOL_CONTEXT_SESSION_FIELDS.iter().find_map(|field| {
request
.protocol_payload()
.context()
.get(*field)
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
})
}
fn first_session_value(body: &Map<String, Value>) -> Option<String> {
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned);
prompt_cache_key
.or_else(|| {
let metadata = body.get("metadata")?.as_object()?;
["session_id", "sessionId"]
.into_iter()
.find_map(|field| metadata.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| {
metadata
.get("user_id")
.and_then(Value::as_str)
.and_then(session_seed_from_user_id)
})
})
.or_else(|| {
SESSION_FIELDS[1..]
.iter()
.find_map(|field| body.get(*field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
}
fn valid_session_seed(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= MAX_SESSION_SEED_BYTES
&& !value.chars().any(char::is_control))
.then_some(value)
}
fn session_seed_from_user_id(value: &str) -> Option<String> {
let value = value.trim();
if let Ok(Value::Object(embedded)) = serde_json::from_str::<Value>(value)
&& let Some(seed) = ["session_id", "sessionId"]
.into_iter()
.find_map(|field| embedded.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
{
return Some(seed.to_owned());
}
value
.rfind("_session_")
.and_then(|index| valid_session_seed(&value[index + "_session_".len()..]))
.map(ToOwned::to_owned)
}
fn resolve_session_identity(
client_api_key_ref: &str,
upstream_model: &str,
explicit_seed: Option<&str>,
body: &Map<String, Value>,
) -> Option<(String, GrokSessionAffinityKey)> {
let model = upstream_model.trim().to_ascii_lowercase();
if client_api_key_ref.is_empty() || model.is_empty() {
return None;
}
if let Some(seed) = explicit_seed {
let upstream_source =
format!("xai:build-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{seed}");
let affinity_source = format!(
"xai:build-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{seed}"
);
return Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
));
}
let (system, first_user) = message_anchors(body);
let first_user = truncate_anchor(&first_user, 200);
if first_user.is_empty() {
return None;
}
let system = truncate_anchor(&system, 100);
let upstream_source = format!(
"xai:build-soft-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{system}:{first_user}"
);
let affinity_source = format!(
"xai:build-soft-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{system}:{first_user}"
);
Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
))
}
fn digest_uuid(source: &str) -> String {
let digest = Sha256::digest(source);
let value = digest[..16]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!(
"{}-{}-{}-{}-{}",
&value[0..8],
&value[8..12],
&value[12..16],
&value[16..20],
&value[20..32]
)
}
fn truncate_anchor(value: &str, max_chars: usize) -> String {
value.trim().chars().take(max_chars).collect()
}
fn message_anchors(body: &Map<String, Value>) -> (String, String) {
let mut system = body
.get("instructions")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| body.get("system").map(flatten_message_content))
.unwrap_or_default();
let mut first_user = String::new();
if let Some(messages) = body.get("messages").and_then(Value::as_array) {
collect_role_anchors(messages, &mut system, &mut first_user);
}
if first_user.is_empty() {
match body.get("input") {
Some(Value::String(value)) => first_user = value.trim().to_owned(),
Some(Value::Array(items)) => {
collect_role_anchors(items, &mut system, &mut first_user);
}
_ => {}
}
}
(system, first_user)
}
fn collect_role_anchors(items: &[Value], system: &mut String, first_user: &mut String) {
for item in items {
let Some(item) = item.as_object() else {
continue;
};
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if !item_type.is_empty() && item_type != "message" {
continue;
}
let role = item
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let content = item
.get("content")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| {
item.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_default();
if content.is_empty() {
continue;
}
match role.as_str() {
"system" | "developer" if system.is_empty() => *system = content,
"user" if first_user.is_empty() => *first_user = content,
"" if first_user.is_empty() => *first_user = content,
_ => {}
}
if !first_user.is_empty() && !system.is_empty() {
break;
}
}
}
fn flatten_message_content(value: &Value) -> String {
match value {
Value::String(value) => value.trim().to_owned(),
Value::Array(parts) => parts
.iter()
.filter_map(Value::as_object)
.filter(|part| {
matches!(
part.get("type").and_then(Value::as_str).unwrap_or_default(),
"" | "text" | "input_text" | "output_text"
)
})
.filter_map(|part| part.get("text").and_then(Value::as_str).map(str::trim))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn normalize_responses_request(
body: &mut Map<String, Value>,
) -> Result<GrokResponseTransform, GrokRequestEncodeError> {
if let Some(response_format) = body.remove("response_format") {
let text = body
.entry("text".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if text.is_null() {
*text = Value::Object(Map::new());
}
let text = text
.as_object_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "text" })?;
if text.get("format").is_none_or(Value::is_null) {
text.insert(
"format".to_owned(),
normalize_response_format(response_format)
.map_err(|error| error.at_field("response_format"))?,
);
for field in ["response_format", "reasoning_effort", "reasoningEffort"] {
if body.contains_key(field) {
return Err(GrokRequestEncodeError::InvalidRequestField { field });
}
}
patch_reasoning_text_types(body);
ToolNormalizer::new().normalize(body)
}
fn normalize_build_request(
body: &mut Map<String, Value>,
upstream_model: &str,
) -> Result<(), GrokRequestEncodeError> {
apply_build_response_defaults(body)?;
normalize_build_reasoning_effort(body, upstream_model);
sanitize_build_model_fields(body, upstream_model);
Ok(())
}
fn apply_build_response_defaults(
body: &mut Map<String, Value>,
) -> Result<(), GrokRequestEncodeError> {
if body.get("store").is_none_or(Value::is_null) {
body.insert("store".to_owned(), Value::Bool(false));
}
let include = body
.entry("include".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
if include.is_null() {
*include = Value::Array(Vec::new());
}
let include = include
.as_array_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "include" })?;
if include.iter().any(|value| !value.is_string()) {
return Err(GrokRequestEncodeError::InvalidRequestField { field: "include" });
}
if !include
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content"))
{
include.push(Value::String("reasoning.encrypted_content".to_owned()));
}
Ok(())
}
fn normalize_build_reasoning_effort(body: &mut Map<String, Value>, upstream_model: &str) {
let supports_effort = grok_model_supports_reasoning_effort(upstream_model);
if let Some(reasoning) = body.get_mut("reasoning").and_then(Value::as_object_mut) {
if let Some(effort) = reasoning.remove("effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort)
{
reasoning.insert("effort".to_owned(), Value::String(effort.to_owned()));
}
if reasoning.is_empty() {
body.remove("reasoning");
}
}
if let Some(effort) = body.remove("reasoning_effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort)
{
body.insert(
"reasoning_effort".to_owned(),
Value::String(effort.to_owned()),
);
}
let camel_effort = body.remove("reasoningEffort");
if !body.contains_key("reasoning_effort")
&& supports_effort
&& let Some(effort) = camel_effort
.as_ref()
.and_then(normalize_grok_reasoning_effort_value)
{
body.insert(
"reasoning_effort".to_owned(),
Value::String(effort.to_owned()),
);
}
if is_grok_composer_model(upstream_model) {
body.remove("reasoning");
body.remove("reasoning_effort");
body.remove("reasoningEffort");
}
}
fn normalize_grok_reasoning_effort_value(value: &Value) -> Option<&'static str> {
let normalized = value
.as_str()?
.trim()
.to_ascii_lowercase()
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' '))
.collect::<String>();
match normalized.as_str() {
"none" => Some("none"),
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"high" | "xhigh" | "extrahigh" | "max" | "ultra" => Some("high"),
_ => None,
}
}
fn strip_grok_provider_prefix(model: &str) -> &str {
let model = model.trim();
let lower = model.to_ascii_lowercase();
["xai/", "x-ai/", "grok/"]
.into_iter()
.find_map(|prefix| {
lower
.starts_with(prefix)
.then(|| model[prefix.len()..].trim())
})
.unwrap_or(model)
}
fn grok_model_last_slug(model: &str) -> String {
model
.trim()
.rsplit_once('/')
.map_or_else(|| model.trim(), |(_, slug)| slug.trim())
.to_ascii_lowercase()
}
fn is_grok_composer_model(model: &str) -> bool {
matches!(
grok_model_last_slug(model).as_str(),
"grok-composer" | "grok-composer-2.5-fast" | "composer-2.5"
)
}
fn grok_model_supports_reasoning_effort(model: &str) -> bool {
matches!(
strip_grok_provider_prefix(model)
.to_ascii_lowercase()
.as_str(),
"grok-4.5"
| "grok-4.5-latest"
| "grok-4.6"
| "grok-4.6-latest"
| "grok-4.3"
| "grok-4.3-latest"
| "grok-3-mini"
| "grok-3-mini-fast"
| "grok-4.20-0309-reasoning"
| "grok-4.20-reasoning"
| "grok-4.20-multi-agent-0309"
)
}
fn sanitize_build_model_fields(body: &mut Map<String, Value>, upstream_model: &str) {
for field in ["prompt_cache_retention", "safety_identifier"] {
body.remove(field);
}
if upstream_model.trim().eq_ignore_ascii_case("grok-4.5") {
for field in [
"presence_penalty",
"presencePenalty",
"frequency_penalty",
"frequencyPenalty",
"stop",
] {
body.remove(field);
}
}
if grok_model_last_slug(upstream_model).starts_with("grok-4.20") {
body.remove("logprobs");
body.remove("top_logprobs");
}
delete_json_field_recursive(body, "external_web_access");
}
fn delete_json_field_recursive(object: &mut Map<String, Value>, field: &str) {
object.remove(field);
for value in object.values_mut() {
delete_json_field_from_value(value, field);
}
}
fn delete_json_field_from_value(value: &mut Value, field: &str) {
match value {
Value::Object(child) => delete_json_field_recursive(child, field),
Value::Array(children) => {
for child in children {
delete_json_field_from_value(child, field);
}
}
_ => {}
}
}
fn normalize_response_format(value: Value) -> Result<Value, GrokRequestEncodeError> {
let Some(format) = value.as_object() else {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
};
if format.get("type").and_then(Value::as_str) != Some("json_schema") {
return Ok(value);
}
let Some(schema) = format.get("json_schema").and_then(Value::as_object) else {
return Ok(value);
};
let mut normalized = Map::new();
normalized.insert("type".to_owned(), Value::String("json_schema".to_owned()));
normalized.extend(
schema
.iter()
.filter(|(key, _)| key.as_str() != "type")
.map(|(key, value)| (key.clone(), value.clone())),
);
Ok(Value::Object(normalized))
}
fn patch_reasoning_text_types(body: &mut Map<String, Value>) {
let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else {
return;
@@ -0,0 +1,144 @@
//! 当前 Responses 参数到 Grok 模型能力的转换。
use super::*;
pub(super) fn normalize_build_request(
body: &mut Map<String, Value>,
upstream_model: &str,
) -> Result<(), GrokRequestEncodeError> {
apply_build_response_defaults(body)?;
normalize_build_reasoning_effort(body, upstream_model);
sanitize_build_model_fields(body, upstream_model);
Ok(())
}
fn apply_build_response_defaults(
body: &mut Map<String, Value>,
) -> Result<(), GrokRequestEncodeError> {
if body.get("store").is_none_or(Value::is_null) {
body.insert("store".to_owned(), Value::Bool(false));
}
let include = body
.entry("include".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
if include.is_null() {
*include = Value::Array(Vec::new());
}
let include = include
.as_array_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "include" })?;
if include.iter().any(|value| !value.is_string()) {
return Err(GrokRequestEncodeError::InvalidRequestField { field: "include" });
}
if !include
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content"))
{
include.push(Value::String("reasoning.encrypted_content".to_owned()));
}
Ok(())
}
fn normalize_build_reasoning_effort(body: &mut Map<String, Value>, upstream_model: &str) {
let supports_effort = grok_model_supports_reasoning_effort(upstream_model);
if let Some(reasoning) = body.get_mut("reasoning").and_then(Value::as_object_mut) {
if let Some(effort) = reasoning.remove("effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort, upstream_model)
{
reasoning.insert("effort".to_owned(), Value::String(effort.to_owned()));
}
if reasoning.is_empty() {
body.remove("reasoning");
}
}
if is_grok_composer_model(upstream_model) {
body.remove("reasoning");
}
}
fn normalize_grok_reasoning_effort_value(value: &Value, model: &str) -> Option<&'static str> {
let normalized = value.as_str()?.trim().to_ascii_lowercase();
match normalized.as_str() {
"none" => Some("none"),
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"xhigh" if matches!(model, "grok-4.6" | "grok-4.6-latest") => Some("xhigh"),
"high" | "xhigh" => Some("high"),
_ => None,
}
}
pub(super) fn strip_grok_provider_prefix(model: &str) -> &str {
let model = model.trim();
let lower = model.to_ascii_lowercase();
["xai/", "x-ai/", "grok/"]
.into_iter()
.find_map(|prefix| {
lower
.starts_with(prefix)
.then(|| model[prefix.len()..].trim())
})
.unwrap_or(model)
}
pub(super) fn grok_model_last_slug(model: &str) -> String {
model
.trim()
.rsplit_once('/')
.map_or_else(|| model.trim(), |(_, slug)| slug.trim())
.to_ascii_lowercase()
}
pub(super) fn is_grok_composer_model(model: &str) -> bool {
matches!(
grok_model_last_slug(model).as_str(),
"grok-composer" | "grok-composer-2.5-fast" | "composer-2.5"
)
}
fn grok_model_supports_reasoning_effort(model: &str) -> bool {
matches!(
strip_grok_provider_prefix(model)
.to_ascii_lowercase()
.as_str(),
"grok-4.5"
| "grok-4.5-latest"
| "grok-4.6"
| "grok-4.6-latest"
| "grok-4.3"
| "grok-4.3-latest"
| "grok-3-mini"
| "grok-3-mini-fast"
| "grok-4.20-0309-reasoning"
| "grok-4.20-reasoning"
| "grok-4.20-multi-agent-0309"
)
}
fn sanitize_build_model_fields(body: &mut Map<String, Value>, upstream_model: &str) {
for field in ["prompt_cache_retention", "safety_identifier"] {
body.remove(field);
}
if upstream_model.trim().eq_ignore_ascii_case("grok-4.5") {
for field in [
"presence_penalty",
"presencePenalty",
"frequency_penalty",
"frequencyPenalty",
"stop",
] {
body.remove(field);
}
}
if ["grok-4.20", "grok-4.3", "grok-4.5", "grok-4.6"]
.iter()
.any(|prefix| grok_model_last_slug(upstream_model).starts_with(prefix))
{
body.remove("logprobs");
body.remove("top_logprobs");
}
body.remove("external_web_access");
}
@@ -29,14 +29,6 @@ impl ToolIdentity {
pub(super) fn is_root_custom_apply_patch(&self) -> bool {
self.kind == ToolKind::Custom && self.namespace.is_empty() && self.name == "apply_patch"
}
pub(super) fn custom_argument_field(&self) -> &'static str {
if self.is_root_custom_apply_patch() {
"patch"
} else {
"input"
}
}
}
#[derive(Debug, Clone)]
@@ -54,7 +46,6 @@ pub(crate) struct GrokResponseTransform {
pub(super) aliases: BTreeMap<String, ToolIdentity>,
pub(super) function_schemas: BTreeMap<String, Value>,
pub(super) visible_tools: Vec<Value>,
pub(super) legacy_local_shell: bool,
pub(super) filter_x_search: bool,
pub(super) injected_tool_types: BTreeSet<String>,
pub(super) client_declared_tools: BTreeSet<String>,
@@ -89,7 +80,6 @@ impl GrokResponseTransform {
self.aliases.is_empty()
&& self.function_schemas.is_empty()
&& self.visible_tools.is_empty()
&& !self.legacy_local_shell
&& !self.filter_x_search
&& self.injected_tool_types.is_empty()
}
@@ -194,7 +184,16 @@ impl GrokResponseTransform {
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
match state.identity.kind {
ToolKind::Custom => {
if state.arguments.len().saturating_add(delta.len())
> MAX_BUFFERED_FUNCTION_ARGUMENTS_BYTES
|| self.stream_argument_bytes.saturating_add(delta.len())
> MAX_TOTAL_BUFFERED_FUNCTION_ARGUMENTS_BYTES
{
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
state.arguments.push_str(delta);
self.stream_argument_bytes =
self.stream_argument_bytes.saturating_add(delta.len());
state.last_delta = value.as_object().cloned().map(|mut payload| {
payload.remove("delta");
payload
@@ -262,7 +261,7 @@ impl GrokResponseTransform {
.and_then(Value::as_str)
.filter(|arguments| !arguments.is_empty())
.unwrap_or(&state.arguments);
let input = decode_custom_tool_input(&state.identity, arguments);
let input = decode_custom_tool_input(arguments);
let mut output = Vec::with_capacity(2);
if let Some(delta) = state.last_delta.as_ref() {
output.push(GrokTransformedWireEvent {
@@ -287,6 +286,9 @@ impl GrokResponseTransform {
),
});
if let Some(state) = self.stream_calls.get_mut(&primary) {
self.stream_argument_bytes = self
.stream_argument_bytes
.saturating_sub(state.arguments.len());
state.arguments.clear();
state.last_delta = None;
}
@@ -452,25 +454,20 @@ impl GrokResponseTransform {
}
fn rewrite_response_value(&self, value: &mut Value) -> Result<(), GrokRequestEncodeError> {
match value {
Value::Array(values) => {
for value in values {
self.rewrite_response_value(value)?;
}
}
Value::Object(object) => {
for value in object.values_mut() {
self.rewrite_response_value(value)?;
}
match string_field(object, "type") {
"function_call" => self.rewrite_function_call(object)?,
"shell_call" if self.legacy_local_shell => {
rewrite_legacy_local_shell_call(object)
// 只转换协议中的输出条目,避免把 metadata、schema 或工具业务结果当成调用。
if let Some(item) = value.get_mut("item").and_then(Value::as_object_mut)
&& string_field(item, "type") == "function_call"
{
self.rewrite_function_call(item)?;
}
for pointer in ["/output", "/response/output"] {
if let Some(items) = value.pointer_mut(pointer).and_then(Value::as_array_mut) {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
if string_field(item, "type") == "function_call" {
self.rewrite_function_call(item)?;
}
_ => {}
}
}
_ => {}
}
Ok(())
}
@@ -515,7 +512,7 @@ impl GrokResponseTransform {
Value::String(identity.namespace.clone()),
);
}
let input = decode_custom_tool_input(identity, string_field(call, "arguments"));
let input = decode_custom_tool_input(string_field(call, "arguments"));
call.insert("input".to_owned(), Value::String(input));
call.remove("arguments");
}
@@ -542,6 +539,8 @@ impl GrokResponseTransform {
call.remove("arguments");
}
}
let kind = string_field(call, "type").to_owned();
retype_tool_item_id(call, "id", &kind);
Ok(())
}
@@ -759,15 +758,38 @@ pub(super) fn custom_tool_stream_payload(
result.insert(key.to_owned(), value.clone());
}
}
retype_tool_item_id(&mut result, "item_id", "custom_tool_call");
Value::Object(result)
}
pub(super) fn decode_custom_tool_input(identity: &ToolIdentity, arguments: &str) -> String {
/// item ID 随工具类型转换,call_id 始终保留原始配对语义。
pub(super) fn retype_tool_item_id(item: &mut Map<String, Value>, field: &str, kind: &str) {
let prefix = match kind {
"function_call" => "fc_",
"custom_tool_call" => "ctc_",
"tool_search_call" => "tsc_",
_ => return,
};
let Some(id) = item.get(field).and_then(Value::as_str) else {
return;
};
if id.starts_with(prefix) {
return;
}
if let Some(suffix) = ["fc_", "ctc_", "tsc_"]
.into_iter()
.find_map(|known| id.strip_prefix(known))
{
item.insert(field.to_owned(), Value::String(format!("{prefix}{suffix}")));
}
}
pub(super) fn decode_custom_tool_input(arguments: &str) -> String {
serde_json::from_str::<Value>(arguments)
.ok()
.and_then(|value| {
value
.get(identity.custom_argument_field())
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
@@ -796,34 +818,6 @@ pub(super) fn decode_apply_patch_arguments(
validate_apply_patch_operation(wrapper.get("operation"))
}
pub(super) fn rewrite_legacy_local_shell_call(call: &mut Map<String, Value>) {
let commands = call
.get("action")
.and_then(Value::as_object)
.and_then(|action| action.get("commands"))
.and_then(Value::as_array)
.map(|commands| {
commands
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
call.insert(
"type".to_owned(),
Value::String("local_shell_call".to_owned()),
);
call.insert(
"action".to_owned(),
json_object([
("type", Value::String("exec".to_owned())),
("command", Value::String(commands)),
]),
);
call.remove("max_output_length");
}
impl fmt::Debug for GrokResponseTransform {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -831,7 +825,6 @@ impl fmt::Debug for GrokResponseTransform {
.field("alias_count", &self.aliases.len())
.field("function_schema_count", &self.function_schemas.len())
.field("visible_tool_count", &self.visible_tools.len())
.field("legacy_local_shell", &self.legacy_local_shell)
.field("filter_x_search", &self.filter_x_search)
.field("injected_tool_types", &self.injected_tool_types)
.finish()
@@ -0,0 +1,395 @@
//! 工具 JSON Schema 校验与参数数值归一化。
use super::*;
/// 仅移除参数根节点的 null 分支,保持嵌套 schema 的业务语义。
pub(super) fn normalize_function_parameters_root(
value: &Value,
) -> Result<Option<Value>, GrokRequestEncodeError> {
let Some(schema) = value.as_object() else {
return Ok(None);
};
let mut normalized = schema.clone();
let mut changed = false;
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
if removed_null {
let remaining = types
.into_iter()
.filter(|value| value.as_str() != Some("null"))
.collect::<Vec<_>>();
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
return Err(invalid_function_parameters_root());
}
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
changed = true;
}
}
for keyword in ["anyOf", "oneOf"] {
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
continue;
};
let removed_null = branches.iter().any(is_null_only_schema);
if !removed_null {
continue;
}
let remaining = branches
.into_iter()
.filter(|branch| !is_null_only_schema(branch))
.collect::<Vec<_>>();
if remaining.is_empty()
|| remaining.iter().any(|branch| {
branch.as_object().is_none_or(|branch| {
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
})
})
{
return Err(invalid_function_parameters_root());
}
if remaining.len() == 1 && normalized.len() == 1 {
normalized = remaining[0]
.as_object()
.cloned()
.ok_or_else(invalid_function_parameters_root)?;
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
} else {
normalized.insert(keyword.to_owned(), Value::Array(remaining));
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
}
changed = true;
}
Ok(changed.then_some(Value::Object(normalized)))
}
fn is_null_only_schema(value: &Value) -> bool {
let Some(schema) = value.as_object() else {
return false;
};
match schema.get("type") {
Some(Value::String(kind)) => kind == "null",
Some(Value::Array(types)) if !types.is_empty() => {
types.iter().all(|kind| kind.as_str() == Some("null"))
}
_ => false,
}
}
fn is_object_root_schema(
schema: &Map<String, Value>,
root: &Map<String, Value>,
visited: &mut BTreeSet<String>,
) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => return kind == "object",
Some(Value::Array(types)) if !types.is_empty() => {
return types.iter().all(|kind| kind.as_str() == Some("object"));
}
Some(_) => return false,
None => {}
}
if schema.contains_key("properties") {
return true;
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return false;
};
if !visited.insert(reference.to_owned()) {
return false;
}
resolve_local_schema_ref(root, reference)
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
}
fn resolve_local_schema_ref<'a>(
root: &'a Map<String, Value>,
reference: &str,
) -> Option<&'a Map<String, Value>> {
if reference == "#" {
return Some(root);
}
let path = reference.strip_prefix("#/")?;
let mut segments = path.split('/');
let first = decode_json_pointer_segment(segments.next()?);
let mut current = root.get(&first)?;
for segment in segments {
let segment = decode_json_pointer_segment(segment);
current = current.as_object()?.get(&segment)?;
}
current.as_object()
}
fn decode_json_pointer_segment(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
pub(super) const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters",
}
}
pub(super) fn encode_function_arguments(
value: Option<&Value>,
) -> Result<String, GrokRequestEncodeError> {
match value {
Some(Value::String(value)) => Ok(value.clone()),
None | Some(Value::Null) => Ok("{}".to_owned()),
Some(value) => serde_json::to_string(value)
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization),
}
}
pub(super) fn normalize_function_arguments(arguments: &str, schema: &Value) -> Option<String> {
if arguments.trim().is_empty() || !schema.is_object() {
return None;
}
let mut value = serde_json::from_str::<Value>(arguments).ok()?;
if !normalize_argument_value(&mut value, schema, schema, 0) {
return None;
}
serde_json::to_string(&value).ok()
}
fn normalize_argument_value(value: &mut Value, schema: &Value, root: &Value, depth: usize) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
let mut changed = false;
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
{
changed |= normalize_argument_value(value, resolved, root, depth + 1);
}
for keyword in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = schema.get(keyword).and_then(Value::as_array) {
for branch in branches {
changed |= normalize_argument_value(value, branch, root, depth + 1);
}
}
}
if schema_requires_integer(schema)
&& let Value::Number(number) = value
&& let Some(normalized) = normalize_integral_number(number)
{
*number = normalized;
return true;
}
match value {
Value::Object(object) => {
let properties = schema.get("properties").and_then(Value::as_object);
let additional = schema
.get("additionalProperties")
.filter(|value| value.is_object());
for (key, item) in object {
let property = properties
.and_then(|properties| properties.get(key))
.or(additional);
if let Some(property) = property {
changed |= normalize_argument_value(item, property, root, depth + 1);
}
}
}
Value::Array(items) => {
let prefix = schema.get("prefixItems").and_then(Value::as_array);
let item_schema = schema.get("items").filter(|value| value.is_object());
for (index, item) in items.iter_mut().enumerate() {
let schema = prefix
.and_then(|prefix| prefix.get(index))
.filter(|value| value.is_object())
.or(item_schema);
if let Some(schema) = schema {
changed |= normalize_argument_value(item, schema, root, depth + 1);
}
}
}
_ => {}
}
changed
}
fn schema_requires_integer(schema: &Map<String, Value>) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => kind == "integer",
Some(Value::Array(kinds)) => {
let mut integer = false;
for kind in kinds.iter().filter_map(Value::as_str) {
if kind == "number" {
return false;
}
integer |= kind == "integer";
}
integer
}
_ => false,
}
}
fn normalize_integral_number(number: &serde_json::Number) -> Option<serde_json::Number> {
let raw = number.to_string();
if raw.len() > MAX_JSON_NUMBER_TEXT_BYTES || !raw.contains(['.', 'e', 'E']) {
return None;
}
let (mantissa, exponent_text) = raw.find(['e', 'E']).map_or((raw.as_str(), ""), |index| {
(&raw[..index], &raw[index + 1..])
});
let (negative, mantissa) = mantissa
.strip_prefix('-')
.map_or((false, mantissa), |value| (true, value));
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let mut digits = format!("{whole}{fraction}")
.trim_start_matches('0')
.to_owned();
if digits.is_empty() {
return "0".parse().ok().filter(|_| raw != "0");
}
let exponent = parse_bounded_decimal_exponent(exponent_text)?;
let decimal_shift = exponent - i64::try_from(fraction.len()).ok()?;
if decimal_shift < 0 {
let fractional_digits = usize::try_from(-decimal_shift).ok()?;
if fractional_digits > digits.len()
|| !digits[digits.len() - fractional_digits..]
.bytes()
.all(|byte| byte == b'0')
{
return None;
}
digits.truncate(digits.len() - fractional_digits);
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() {
return "0".parse().ok();
}
digits = trimmed.to_owned();
} else if decimal_shift > 0 {
let shift = usize::try_from(decimal_shift).ok()?;
if shift
> MAX_EXACT_JSON_INTEGER_TEXT
.len()
.saturating_sub(digits.len())
{
return None;
}
digits.extend(std::iter::repeat_n('0', shift));
}
if digits.len() > MAX_EXACT_JSON_INTEGER_TEXT.len()
|| (digits.len() == MAX_EXACT_JSON_INTEGER_TEXT.len()
&& digits.as_str() > MAX_EXACT_JSON_INTEGER_TEXT)
{
return None;
}
let normalized = if negative {
format!("-{digits}")
} else {
digits
};
if normalized == raw {
None
} else {
normalized.parse().ok()
}
}
pub(super) fn exact_nonnegative_sequence(value: &Value) -> Option<u64> {
let number = value.as_number()?;
let sequence = number
.as_u64()
.or_else(|| normalize_integral_number(number)?.as_u64())?;
(sequence <= MAX_EXACT_JSON_INTEGER).then_some(sequence)
}
fn parse_bounded_decimal_exponent(raw: &str) -> Option<i64> {
if raw.is_empty() {
return Some(0);
}
let (sign, digits) = if let Some(value) = raw.strip_prefix('+') {
(1_i64, value)
} else if let Some(value) = raw.strip_prefix('-') {
(-1_i64, value)
} else {
(1_i64, raw)
};
if digits.is_empty() {
return None;
}
let digits = digits.trim_start_matches('0');
if digits.is_empty() {
return Some(0);
}
if digits.len() > 9 {
return None;
}
digits.parse::<i64>().ok().map(|value| sign * value)
}
fn resolve_local_schema_value_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
if reference == "#" {
return Some(root);
}
let pointer = reference.strip_prefix('#')?;
if !pointer.starts_with('/') {
return None;
}
root.pointer(pointer)
}
pub(super) fn schema_contains_integer(schema: &Value, depth: usize) -> bool {
let mut visited = BTreeSet::new();
schema_contains_reachable_integer(schema, schema, &mut visited, depth)
}
fn schema_contains_reachable_integer(
schema: &Value,
root: &Value,
visited_refs: &mut BTreeSet<String>,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
if schema_requires_integer(schema) {
return true;
}
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& visited_refs.insert(reference.to_owned())
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
&& schema_contains_reachable_integer(resolved, root, visited_refs, depth + 1)
{
return true;
}
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if schema
.get(keyword)
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.iter().any(|branch| {
schema_contains_reachable_integer(branch, root, visited_refs, depth + 1)
})
})
{
return true;
}
}
for keyword in ["items", "additionalProperties"] {
if schema.get(keyword).is_some_and(|child| {
schema_contains_reachable_integer(child, root, visited_refs, depth + 1)
}) {
return true;
}
}
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
properties.values().any(|property| {
schema_contains_reachable_integer(property, root, visited_refs, depth + 1)
})
})
}
@@ -1,6 +1,6 @@
//! Codex/OpenAI 工具声明与历史调用到 Grok 工具合同的归一化。
use super::response::{ToolIdentity, ToolKind};
use super::response::{ToolIdentity, ToolKind, retype_tool_item_id};
use super::*;
pub(super) struct ToolNormalizer {
@@ -9,7 +9,6 @@ pub(super) struct ToolNormalizer {
deferred_surfaces: Vec<String>,
client_search_tool: Option<Map<String, Value>>,
server_search_eager: bool,
native_shell: bool,
}
pub(super) struct NormalizedInputItems {
@@ -32,7 +31,6 @@ impl ToolNormalizer {
deferred_surfaces: Vec::new(),
client_search_tool: None,
server_search_eager: false,
native_shell: false,
}
}
@@ -48,7 +46,6 @@ impl ToolNormalizer {
deferred_surfaces: Vec::new(),
client_search_tool: None,
server_search_eager: false,
native_shell: false,
}
}
@@ -182,7 +179,7 @@ impl ToolNormalizer {
| "web_search_2025_08_26" => self.normalize_web_search_tool(tool, kind),
"mcp" => self.normalize_mcp_tool(tool, client_search, force),
"shell" => self.normalize_shell_tool(tool),
"local_shell" => self.normalize_legacy_local_shell_tool(tool),
"local_shell" => Err(GrokRequestEncodeError::InvalidRequestNormalization),
"apply_patch" => self.normalize_apply_patch_tool(tool),
"x_search" | "collections_search" | "file_search" | "code_execution"
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
@@ -310,17 +307,31 @@ impl ToolNormalizer {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let identity = ToolIdentity::new(ToolKind::Custom, namespace, name);
let argument_field = identity.custom_argument_field();
let description = if identity.is_root_custom_apply_patch() {
"The apply_patch tool edits files using Codex patch format. Provide the complete raw patch text in the patch string field.".to_owned()
} else {
let mut description = string_field(tool, "description").trim().to_owned();
if !description.is_empty() {
description.push('\n');
}
description.push_str("Provide the custom tool input in the input string field.");
description
};
let argument_field = "input";
// function 不支持 custom 的 grammar 字段,必须把约束连同原说明交给模型。
let mut description = format!(
"This function wraps a custom tool. The original instructions below describe the \
decoded {argument_field} string.\n{}",
string_field(tool, "description").trim()
);
if let Some(format) = tool.get("format").and_then(Value::as_object)
&& let Some(definition) = format.get("definition").and_then(Value::as_str)
{
description.push_str("\nInput grammar (syntax: ");
description.push_str(string_field(format, "syntax"));
description.push_str("):\n");
description.push_str(definition);
}
description.push_str(&format!(
"\nProvide the complete raw tool input in the {argument_field} string field. \
Encode it as a JSON string once; decoded newlines must be actual newline characters."
));
if identity.is_root_custom_apply_patch() {
description.push_str(
" The patch must start with exactly '*** Begin Patch' and end with exactly \
'*** End Patch', each on its own line. Do not add Markdown fences.",
);
}
let alias = self.alias(identity);
Ok(vec![json_object([
("type", Value::String("function".to_owned())),
@@ -392,136 +403,6 @@ pub(super) fn optional_array(
}
}
/// Grok Build 要求 function parameters 的根节点必为 object;Codex 会为可选参数
/// 生成 `object | null`,这里只移除根节点 nullability,嵌套字段保持原样。
pub(super) fn normalize_function_parameters_root(
value: &Value,
) -> Result<Option<Value>, GrokRequestEncodeError> {
let Some(schema) = value.as_object() else {
return Ok(None);
};
let mut normalized = schema.clone();
let mut changed = false;
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
if removed_null {
let remaining = types
.into_iter()
.filter(|value| value.as_str() != Some("null"))
.collect::<Vec<_>>();
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
return Err(invalid_function_parameters_root());
}
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
changed = true;
}
}
for keyword in ["anyOf", "oneOf"] {
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
continue;
};
let removed_null = branches.iter().any(is_null_only_schema);
if !removed_null {
continue;
}
let remaining = branches
.into_iter()
.filter(|branch| !is_null_only_schema(branch))
.collect::<Vec<_>>();
if remaining.is_empty()
|| remaining.iter().any(|branch| {
branch.as_object().is_none_or(|branch| {
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
})
})
{
return Err(invalid_function_parameters_root());
}
if remaining.len() == 1 && normalized.len() == 1 {
normalized = remaining[0]
.as_object()
.cloned()
.ok_or_else(invalid_function_parameters_root)?;
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
} else {
normalized.insert(keyword.to_owned(), Value::Array(remaining));
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
}
changed = true;
}
Ok(changed.then_some(Value::Object(normalized)))
}
pub(super) fn is_null_only_schema(value: &Value) -> bool {
let Some(schema) = value.as_object() else {
return false;
};
match schema.get("type") {
Some(Value::String(kind)) => kind == "null",
Some(Value::Array(types)) if !types.is_empty() => {
types.iter().all(|kind| kind.as_str() == Some("null"))
}
_ => false,
}
}
pub(super) fn is_object_root_schema(
schema: &Map<String, Value>,
root: &Map<String, Value>,
visited: &mut BTreeSet<String>,
) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => return kind == "object",
Some(Value::Array(types)) if !types.is_empty() => {
return types.iter().all(|kind| kind.as_str() == Some("object"));
}
Some(_) => return false,
None => {}
}
if schema.contains_key("properties") {
return true;
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return false;
};
if !visited.insert(reference.to_owned()) {
return false;
}
resolve_local_schema_ref(root, reference)
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
}
pub(super) fn resolve_local_schema_ref<'a>(
root: &'a Map<String, Value>,
reference: &str,
) -> Option<&'a Map<String, Value>> {
if reference == "#" {
return Some(root);
}
let path = reference.strip_prefix("#/")?;
let mut segments = path.split('/');
let first = decode_json_pointer_segment(segments.next()?);
let mut current = root.get(&first)?;
for segment in segments {
let segment = decode_json_pointer_segment(segment);
current = current.as_object()?.get(&segment)?;
}
current.as_object()
}
pub(super) fn decode_json_pointer_segment(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
pub(super) const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters",
}
}
pub(super) fn inspect_tool_search(tools: &[Value]) -> Result<bool, GrokRequestEncodeError> {
let mut client_search = false;
let mut server_search = false;
@@ -560,6 +441,7 @@ pub(super) fn json_object<const N: usize>(entries: [(&str, Value); N]) -> Value
pub(super) fn without_defer_loading(tool: &Map<String, Value>) -> Map<String, Value> {
let mut converted = tool.clone();
converted.remove("defer_loading");
converted.remove("external_web_access");
converted
}
@@ -700,30 +582,9 @@ impl ToolNormalizer {
&mut self,
tool: &Map<String, Value>,
) -> Result<Vec<Value>, GrokRequestEncodeError> {
if self.response.legacy_local_shell {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
self.native_shell = true;
Ok(vec![Value::Object(without_defer_loading(tool))])
}
fn normalize_legacy_local_shell_tool(
&mut self,
_tool: &Map<String, Value>,
) -> Result<Vec<Value>, GrokRequestEncodeError> {
if self.native_shell || self.response.legacy_local_shell {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
self.response.legacy_local_shell = true;
Ok(vec![json_object([
("type", Value::String("shell".to_owned())),
(
"environment",
json_object([("type", Value::String("local".to_owned()))]),
),
])])
}
fn normalize_apply_patch_tool(
&mut self,
_tool: &Map<String, Value>,
@@ -1081,14 +942,9 @@ impl ToolNormalizer {
rewritten.push(Value::Object(normalize_apply_patch_output_input(item)?));
}
"agent_message" => rewritten.push(normalize_agent_message_input(item)),
"local_shell_call" => {
rewritten.push(Value::Object(normalize_legacy_local_shell_call_input(
item,
)?));
"local_shell_call" | "local_shell_call_output" => {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
"local_shell_call_output" => rewritten.push(Value::Object(
normalize_legacy_local_shell_output_input(item)?,
)),
"shell_call_output" => {
rewritten.push(Value::Object(normalize_shell_call_output_input(item)?));
}
@@ -1244,6 +1100,7 @@ impl ToolNormalizer {
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));
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
@@ -1259,11 +1116,9 @@ impl ToolNormalizer {
let call_id = required_trimmed_string(item, "call_id")?;
let namespace = string_field(item, "namespace").trim();
let identity = ToolIdentity::new(ToolKind::Custom, namespace, name);
let arguments = serde_json::to_string(&json_object([(
identity.custom_argument_field(),
Value::String(input.to_owned()),
)]))
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization)?;
let arguments =
serde_json::to_string(&json_object([("input", Value::String(input.to_owned()))]))
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization)?;
let alias = self.alias(identity);
let mut converted = item.clone();
strip_grok_internal_keys(&mut converted);
@@ -1273,6 +1128,7 @@ impl ToolNormalizer {
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));
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
@@ -1356,12 +1212,19 @@ impl ToolNormalizer {
}
let arguments = encode_function_arguments(item.get("arguments"))?;
let alias = self.alias(ToolIdentity::new(ToolKind::ToolSearch, "", "tool_search"));
Ok(Map::from_iter([
let mut converted = 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)),
]))
]);
for field in ["id", "status"] {
if let Some(value) = item.get(field).filter(|value| !value.is_null()) {
converted.insert(field.to_owned(), value.clone());
}
}
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
fn normalize_tool_search_output(
@@ -1463,277 +1326,6 @@ pub(super) fn required_trimmed_string<'a>(
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
}
pub(super) fn encode_function_arguments(
value: Option<&Value>,
) -> Result<String, GrokRequestEncodeError> {
match value {
Some(Value::String(value)) => Ok(value.clone()),
None | Some(Value::Null) => Ok("{}".to_owned()),
Some(value) => serde_json::to_string(value)
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization),
}
}
pub(super) fn normalize_function_arguments(arguments: &str, schema: &Value) -> Option<String> {
if arguments.trim().is_empty() || !schema.is_object() {
return None;
}
let mut value = serde_json::from_str::<Value>(arguments).ok()?;
if !normalize_argument_value(&mut value, schema, schema, 0) {
return None;
}
serde_json::to_string(&value).ok()
}
pub(super) fn normalize_argument_value(
value: &mut Value,
schema: &Value,
root: &Value,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
let mut changed = false;
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
{
changed |= normalize_argument_value(value, resolved, root, depth + 1);
}
for keyword in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = schema.get(keyword).and_then(Value::as_array) {
for branch in branches {
changed |= normalize_argument_value(value, branch, root, depth + 1);
}
}
}
if schema_requires_integer(schema)
&& let Value::Number(number) = value
&& let Some(normalized) = normalize_integral_number(number)
{
*number = normalized;
return true;
}
match value {
Value::Object(object) => {
let properties = schema.get("properties").and_then(Value::as_object);
let additional = schema
.get("additionalProperties")
.filter(|value| value.is_object());
for (key, item) in object {
let property = properties
.and_then(|properties| properties.get(key))
.or(additional);
if let Some(property) = property {
changed |= normalize_argument_value(item, property, root, depth + 1);
}
}
}
Value::Array(items) => {
let prefix = schema.get("prefixItems").and_then(Value::as_array);
let item_schema = schema.get("items").filter(|value| value.is_object());
for (index, item) in items.iter_mut().enumerate() {
let schema = prefix
.and_then(|prefix| prefix.get(index))
.filter(|value| value.is_object())
.or(item_schema);
if let Some(schema) = schema {
changed |= normalize_argument_value(item, schema, root, depth + 1);
}
}
}
_ => {}
}
changed
}
pub(super) fn schema_requires_integer(schema: &Map<String, Value>) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => kind == "integer",
Some(Value::Array(kinds)) => {
let mut integer = false;
for kind in kinds.iter().filter_map(Value::as_str) {
if kind == "number" {
return false;
}
integer |= kind == "integer";
}
integer
}
_ => false,
}
}
pub(super) fn normalize_integral_number(number: &serde_json::Number) -> Option<serde_json::Number> {
let raw = number.to_string();
if raw.len() > MAX_JSON_NUMBER_TEXT_BYTES || !raw.contains(['.', 'e', 'E']) {
return None;
}
let (mantissa, exponent_text) = raw.find(['e', 'E']).map_or((raw.as_str(), ""), |index| {
(&raw[..index], &raw[index + 1..])
});
let (negative, mantissa) = mantissa
.strip_prefix('-')
.map_or((false, mantissa), |value| (true, value));
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let mut digits = format!("{whole}{fraction}")
.trim_start_matches('0')
.to_owned();
if digits.is_empty() {
return "0".parse().ok().filter(|_| raw != "0");
}
let exponent = parse_bounded_decimal_exponent(exponent_text)?;
let decimal_shift = exponent - i64::try_from(fraction.len()).ok()?;
if decimal_shift < 0 {
let fractional_digits = usize::try_from(-decimal_shift).ok()?;
if fractional_digits > digits.len()
|| !digits[digits.len() - fractional_digits..]
.bytes()
.all(|byte| byte == b'0')
{
return None;
}
digits.truncate(digits.len() - fractional_digits);
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() {
return "0".parse().ok();
}
digits = trimmed.to_owned();
} else if decimal_shift > 0 {
let shift = usize::try_from(decimal_shift).ok()?;
if shift
> MAX_EXACT_JSON_INTEGER_TEXT
.len()
.saturating_sub(digits.len())
{
return None;
}
digits.extend(std::iter::repeat_n('0', shift));
}
if digits.len() > MAX_EXACT_JSON_INTEGER_TEXT.len()
|| (digits.len() == MAX_EXACT_JSON_INTEGER_TEXT.len()
&& digits.as_str() > MAX_EXACT_JSON_INTEGER_TEXT)
{
return None;
}
let normalized = if negative {
format!("-{digits}")
} else {
digits
};
if normalized == raw {
None
} else {
normalized.parse().ok()
}
}
pub(super) fn exact_nonnegative_sequence(value: &Value) -> Option<u64> {
let number = value.as_number()?;
let sequence = number
.as_u64()
.or_else(|| normalize_integral_number(number)?.as_u64())?;
(sequence <= MAX_EXACT_JSON_INTEGER).then_some(sequence)
}
pub(super) fn parse_bounded_decimal_exponent(raw: &str) -> Option<i64> {
if raw.is_empty() {
return Some(0);
}
let (sign, digits) = if let Some(value) = raw.strip_prefix('+') {
(1_i64, value)
} else if let Some(value) = raw.strip_prefix('-') {
(-1_i64, value)
} else {
(1_i64, raw)
};
if digits.is_empty() {
return None;
}
let digits = digits.trim_start_matches('0');
if digits.is_empty() {
return Some(0);
}
if digits.len() > 9 {
return None;
}
digits.parse::<i64>().ok().map(|value| sign * value)
}
pub(super) fn resolve_local_schema_value_ref<'a>(
root: &'a Value,
reference: &str,
) -> Option<&'a Value> {
if reference == "#" {
return Some(root);
}
let pointer = reference.strip_prefix('#')?;
if !pointer.starts_with('/') {
return None;
}
root.pointer(pointer)
}
pub(super) fn schema_contains_integer(schema: &Value, depth: usize) -> bool {
let mut visited = BTreeSet::new();
schema_contains_reachable_integer(schema, schema, &mut visited, depth)
}
pub(super) fn schema_contains_reachable_integer(
schema: &Value,
root: &Value,
visited_refs: &mut BTreeSet<String>,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
if schema_requires_integer(schema) {
return true;
}
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& visited_refs.insert(reference.to_owned())
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
&& schema_contains_reachable_integer(resolved, root, visited_refs, depth + 1)
{
return true;
}
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if schema
.get(keyword)
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.iter().any(|branch| {
schema_contains_reachable_integer(branch, root, visited_refs, depth + 1)
})
})
{
return true;
}
}
for keyword in ["items", "additionalProperties"] {
if schema.get(keyword).is_some_and(|child| {
schema_contains_reachable_integer(child, root, visited_refs, depth + 1)
}) {
return true;
}
}
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
properties.values().any(|property| {
schema_contains_reachable_integer(property, root, visited_refs, depth + 1)
})
})
}
pub(super) fn encode_tool_output(value: Option<&Value>) -> Result<String, GrokRequestEncodeError> {
match value {
None | Some(Value::Null) => Ok(String::new()),
@@ -338,6 +338,31 @@ async fn xai_admin_provider_projects_cached_quota_models_and_canonical_export()
.await
.expect("cached quota");
assert!(quota.windows.is_empty());
store
.compare_and_swap_quota(gateway_core::account::QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(
serde_json::from_value(serde_json::json!({
"subscriptionTier": "Free",
"config": {}
}))
.expect("quota document"),
),
observed_at: SystemTime::now(),
state: account.quota(),
})
.await
.expect("store subscription snapshot");
let quota = admin
.quota(ProviderQuotaRequest {
account_id: account.id().clone(),
refresh: false,
rolling_usage: None,
})
.await
.expect("cached subscription");
assert_eq!(quota.plan_type.as_deref(), Some("Free"));
let models = admin
.models(account.id(), false)
.await
@@ -388,6 +413,7 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() {
let import_error = admin
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: None,
document: ProviderDocument::new(OpaqueProviderData::new(Map::new())),
})
.await
@@ -13,7 +13,7 @@ use gateway_core::provider_ports::{
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderStoreError,
};
use provider_xai::{
GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
GROK_SUBSCRIPTION_URL, GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
GrokCatalogCache, GrokCatalogScope, GrokCredentialCatalogCache, GrokCredentialCatalogError,
GrokCredentialCatalogSeed, GrokCredentialRepository, GrokModelCatalogRequest,
@@ -88,6 +88,7 @@ impl GrokModelCatalogTransport for QueueCatalogTransport {
struct QueueBillingTransport {
calls: AtomicUsize,
subscription: Option<Vec<u8>>,
responses: Mutex<VecDeque<Result<GrokBillingTransportResponse, GrokBillingTransportError>>>,
}
@@ -95,6 +96,7 @@ impl QueueBillingTransport {
fn success(body: &[u8]) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
subscription: None,
responses: Mutex::new(VecDeque::from([Ok(GrokBillingTransportResponse::new(
body,
))])),
@@ -104,6 +106,7 @@ impl QueueBillingTransport {
fn failure() -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
subscription: None,
responses: Mutex::new(VecDeque::from([Err(GrokBillingTransportError::new(
GrokBillingTransportErrorKind::Unavailable,
))])),
@@ -112,7 +115,17 @@ impl QueueBillingTransport {
}
impl GrokBillingTransport for QueueBillingTransport {
fn execute(&self, _: GrokBillingRequest) -> GrokBillingTransportFuture<'_> {
fn execute(&self, request: GrokBillingRequest) -> GrokBillingTransportFuture<'_> {
if request.endpoint().as_str() == GROK_SUBSCRIPTION_URL {
let response = self
.subscription
.clone()
.map(GrokBillingTransportResponse::new)
.ok_or_else(|| {
GrokBillingTransportError::new(GrokBillingTransportErrorKind::Unavailable)
});
return Box::pin(async move { response });
}
self.calls.fetch_add(1, Ordering::SeqCst);
let response = self
.responses
@@ -161,7 +174,14 @@ struct MutatingBillingTransport {
}
impl GrokBillingTransport for MutatingBillingTransport {
fn execute(&self, _: GrokBillingRequest) -> GrokBillingTransportFuture<'_> {
fn execute(&self, request: GrokBillingRequest) -> GrokBillingTransportFuture<'_> {
if request.endpoint().as_str() == GROK_SUBSCRIPTION_URL {
return Box::pin(async {
Err(GrokBillingTransportError::new(
GrokBillingTransportErrorKind::Unavailable,
))
});
}
let store = Arc::clone(&self.store);
let mutation = self.mutation.lock().expect("mutation").take();
let body = self.body.clone();
@@ -665,6 +685,7 @@ async fn quota_refresh_persists_dynamic_provider_document_and_projects_known_fie
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
assert_eq!(snapshot.billing().used_percent(), Some(37.5));
assert_eq!(snapshot.billing().plan_type(), None);
assert_eq!(
snapshot.billing().period_kind(),
provider_xai::GrokQuotaPeriodKind::Weekly
@@ -681,6 +702,37 @@ async fn quota_refresh_persists_dynamic_provider_document_and_projects_known_fie
);
}
#[tokio::test]
async fn quota_refresh_persists_subscription_without_user_profile_and_reads_it_from_cache() {
let (store, repository) =
repository_with_accounts(&[("subscription", "subject-subscription")]).await;
let mut transport = QueueBillingTransport::success(br#"{"config":{"creditUsagePercent":25}}"#);
Arc::get_mut(&mut transport).expect("unique transport").subscription = Some(
br#"{"userId":"verified-user","email":"private@example.com","subscriptionTier":"SuperGrokPro"}"#.to_vec(),
);
let service = crate::support::grok_quota_service(repository, transport.clone());
let snapshot = service
.refresh_account(&account_id("subscription"))
.await
.expect("refresh quota");
assert_eq!(snapshot.billing().plan_type(), Some("SuperGrokPro"));
let cached = service
.read_account(&account_id("subscription"))
.await
.expect("read quota")
.expect("cached quota");
assert_eq!(cached.billing().plan_type(), Some("SuperGrokPro"));
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
let stored = store
.get_quotas(&[account_id("subscription")])
.await
.expect("persisted quota");
let document = stored[0].quota.expose_to_provider();
assert_eq!(document["subscriptionTier"], "SuperGrokPro");
assert!(document.get("email").is_none());
assert!(document.get("userId").is_none());
}
#[tokio::test]
async fn billing_percent_does_not_rewrite_quota_access_fact() {
let (store, repository) =
@@ -204,6 +204,45 @@ fn strict_oauth_account_document_should_parse_without_exposing_credentials() {
let _candidate = entry.into_candidate();
}
#[test]
fn import_default_proxy_preserves_explicit_account_exits() {
let default_proxy =
gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap();
for (field, value, expected) in [
(
None,
serde_json::Value::Null,
Some("http://default.example:8080/"),
),
(
Some("outboundProxyUrl"),
serde_json::json!("socks5h://override.example:1080"),
Some("socks5h://override.example:1080"),
),
(Some("outbound_proxy_url"), serde_json::json!(""), None),
(Some("outboundProxyUrl"), serde_json::Value::Null, None),
(Some("proxy_key"), serde_json::Value::Null, None),
] {
let mut document: serde_json::Value =
serde_json::from_slice(&oauth_account_document()).unwrap();
if let Some(field) = field {
document["accounts"][0][field] = value;
}
let parsed = GrokOAuthImportDocument::parse_json_with_proxy(
&serde_json::to_vec(&document).unwrap(),
Some(&default_proxy),
)
.unwrap();
assert_eq!(
parsed.into_entries()[0]
.outbound_proxy()
.map(|proxy| proxy.endpoint())
.as_deref(),
expected
);
}
}
#[test]
fn oauth_account_document_should_reject_api_key_credential_field() {
let mut document: serde_json::Value =
@@ -172,7 +172,7 @@ fn stateful_sse_with_custom_tool_call(encrypted_content: &str, input: &str) -> V
}
fn custom_apply_patch_sse(patch: &str) -> Vec<u8> {
let arguments = serde_json::to_string(&json!({"patch": patch})).expect("patch arguments");
let arguments = serde_json::to_string(&json!({"input": patch})).expect("patch arguments");
let arguments_json = serde_json::to_string(&arguments).expect("arguments JSON string");
format!(
concat!(
@@ -1481,6 +1481,7 @@ async fn compaction_should_pin_recorded_account_and_forward_session_headers() {
Map::from_iter([
("account_id".to_owned(), json!("acct_provider")),
("session_id".to_owned(), json!("cache-session")),
("response_stored".to_owned(), json!(true)),
(
"transcript".to_owned(),
json!([{"client_input":{"type":"message","role":"user","content":"must-not-be-replayed"}}]),
@@ -2428,6 +2429,96 @@ async fn native_previous_response_pins_account_and_sends_upstream_handle() {
);
}
#[tokio::test]
async fn continuation_should_inherit_unchanged_instructions_and_replay_changed_instructions() {
for (stored, instructions) in [
(true, Some("original")),
(true, Some("replacement")),
(true, None),
(false, Some("original")),
] {
let transport =
StubInferenceTransport::sequence([InferenceMode::Success, InferenceMode::Success]);
let provider = provider(StubSelector::success(), transport.clone()).await;
let first_body = json!({
"model": "client-model", "instructions": "original", "store": stored,
"input": [{"type": "message", "role": "user", "content": "first"}]
});
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", first_body.as_object().expect("body").clone())
.expect("payload"),
));
let mut first = provider
.execute(
provider_request_with_operation("xai", operation),
context(CancellationToken::new(), None),
)
.await
.expect("first stream");
let state = collect_provider_state(&mut first)
.await
.expect("session state");
let pin = NativeContinuationPin::new(
PreviousResponseId::new("resp_previous"),
PreviousResponseId::new("resp_upstream_previous"),
gateway_core::policy::ClientApiKeyId::new("key_xai_contract").expect("client key"),
ProviderKind::new("xai").expect("provider"),
account_id("provider"),
);
let mut continued = provider
.execute(
provider_request_with_operation(
"xai",
operation_with_state(
json!({
"model": "client-model", "instructions": instructions,
"previous_response_id": "resp_previous",
"input": [{"type": "message", "role": "user", "content": "second"}]
}),
state,
),
),
context(
CancellationToken::new(),
Some(ContinuationBinding::Pinned(pin)),
),
)
.await
.expect("continued stream");
let next_state = collect_provider_state(&mut continued)
.await
.expect("next state");
assert_eq!(
next_state.payload().get("instructions"),
Some(&json!(instructions))
);
let requests = transport.requests.lock().expect("requests");
let body: Value = serde_json::from_slice(requests[1].body()).expect("upstream body");
if stored && instructions == Some("original") {
assert_eq!(
body.get("previous_response_id"),
Some(&json!("resp_upstream_previous"))
);
assert!(body.get("instructions").is_none());
assert_eq!(
body.get("input").and_then(Value::as_array).map(Vec::len),
Some(1)
);
} else {
assert!(body.get("previous_response_id").is_none());
assert_eq!(body.get("instructions"), Some(&json!(instructions)));
assert_eq!(body.pointer("/input/0/content"), Some(&json!("first")));
assert_eq!(
body.get("input")
.and_then(Value::as_array)
.and_then(|items| items.last())
.and_then(|item| item.get("content")),
Some(&json!("second"))
);
}
}
}
#[tokio::test]
async fn native_previous_response_does_not_allow_quota_or_rate_limit_account_rotation() {
let transport = StubInferenceTransport::error(
@@ -2740,6 +2831,7 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco
"openai",
Map::from_iter([
("model".to_owned(), json!("client-model")),
("store".to_owned(), json!(true)),
("prompt_cache_key".to_owned(), json!("conversation-42")),
(
"input".to_owned(),
@@ -2935,7 +3027,7 @@ async fn replay_owner_should_reencode_custom_apply_patch_call_for_grok() {
assert_eq!(replayed_call.get("type"), Some(&json!("function_call")));
assert_eq!(replayed_call.get("name"), Some(&json!("apply_patch")));
assert_eq!(replayed_call.get("input"), None);
assert_eq!(arguments, json!({"patch": patch}));
assert_eq!(arguments, json!({"input": patch}));
assert!(!replay_input.iter().any(|item| {
item.get("type").and_then(serde_json::Value::as_str) == Some("custom_tool_call")
}));
@@ -2957,6 +3049,7 @@ async fn missing_native_response_should_be_replay_safe_for_the_same_account() {
Map::from_iter([
("account_id".to_owned(), json!("acct_provider")),
("session_id".to_owned(), json!("cache-session")),
("response_stored".to_owned(), json!(true)),
("transcript".to_owned(), json!([])),
]),
)
@@ -158,10 +158,14 @@ impl GrokEndpointPolicy for LoopbackGrokEndpointPolicy {
}
fn route_billing(&self, url: &Url) -> Option<Url> {
(url.as_str() == GROK_BILLING_URL).then(|| {
matches!(
url.as_str(),
GROK_BILLING_URL | provider_xai::GROK_SUBSCRIPTION_URL
)
.then(|| {
let mut endpoint = self.origin();
endpoint.set_path("/v1/billing");
endpoint.set_query(Some("format=credits"));
endpoint.set_path(url.path());
endpoint.set_query(url.query());
endpoint
})
}
@@ -599,20 +599,20 @@ fn decoder_should_restore_custom_apply_patch_arguments_as_raw_input() {
"tools": [{"type": "custom", "name": "apply_patch"}]
}));
let arguments =
serde_json::to_string(&serde_json::json!({"patch": patch})).expect("apply_patch arguments");
serde_json::to_string(&serde_json::json!({"input": patch})).expect("apply_patch arguments");
let arguments_json = serde_json::to_string(&arguments).expect("arguments JSON string");
let body = format!(
concat!(
"event: response.created\n",
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_custom_patch\"}}}}\n\n",
"event: response.output_item.added\n",
"data: {{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{{\"id\":\"item_custom_patch\",\"type\":\"function_call\",\"call_id\":\"call_custom_patch\",\"name\":\"apply_patch\"}}}}\n\n",
"data: {{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{{\"id\":\"fc_custom_patch\",\"type\":\"function_call\",\"call_id\":\"call_custom_patch\",\"name\":\"apply_patch\"}}}}\n\n",
"event: response.function_call_arguments.delta\n",
"data: {{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item_custom_patch\",\"call_id\":\"call_custom_patch\",\"output_index\":0,\"delta\":{arguments_json}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_custom_patch\",\"call_id\":\"call_custom_patch\",\"output_index\":0,\"delta\":{arguments_json}}}\n\n",
"event: response.function_call_arguments.done\n",
"data: {{\"type\":\"response.function_call_arguments.done\",\"item_id\":\"item_custom_patch\",\"call_id\":\"call_custom_patch\",\"output_index\":0,\"arguments\":{arguments_json}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_custom_patch\",\"call_id\":\"call_custom_patch\",\"output_index\":0,\"arguments\":{arguments_json}}}\n\n",
"event: response.output_item.done\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"item_custom_patch\",\"type\":\"function_call\",\"call_id\":\"call_custom_patch\",\"name\":\"apply_patch\",\"arguments\":{arguments_json}}}}}\n\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"fc_custom_patch\",\"type\":\"function_call\",\"call_id\":\"call_custom_patch\",\"name\":\"apply_patch\",\"arguments\":{arguments_json}}}}}\n\n",
"event: response.completed\n",
"data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_custom_patch\",\"status\":\"completed\"}}}}\n\n",
),
@@ -623,6 +623,15 @@ fn decoder_should_restore_custom_apply_patch_arguments_as_raw_input() {
.push(body.as_bytes())
.expect("custom apply_patch stream");
let wire = wire_events(&events);
for event in &wire {
if let Some(item_id) = event.data().get("item_id") {
assert_eq!(item_id, "ctc_custom_patch");
}
if let Some(item) = event.data().get("item") {
assert_eq!(item["id"], "ctc_custom_patch");
assert_eq!(item["call_id"], "call_custom_patch");
}
}
assert!(wire.iter().any(|event| {
event.event_type() == Some("response.custom_tool_call_input.delta")
@@ -705,6 +714,86 @@ fn decoder_should_buffer_only_custom_arguments_until_the_done_event() {
}));
}
#[test]
fn decoder_should_restore_only_protocol_items_and_preserve_business_objects() {
let request = tool_request(serde_json::json!({
"model": "client",
"input": "render",
"tools": [{"type": "custom", "name": "render"}]
}));
let business = serde_json::json!({
"id": "fc_business", "type": "function_call", "name": "render",
"arguments": "{\"input\":\"keep me\"}"
});
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.6", &request);
let frame = encode_sse_event(
"response.output_item.added",
&serde_json::json!({
"type": "response.output_item.added",
"output_index": 0,
"metadata": business,
"item": {
"id": "fc_render", "type": "function_call", "call_id": "call_render",
"name": "render", "arguments": "{\"input\":\"hello\"}",
"metadata": business
}
})
.to_string(),
);
let events = decoder.push(frame.as_bytes()).expect("custom item");
let wire = wire_events(&events);
let event = wire[0].data();
assert_eq!(
event.pointer("/item/id"),
Some(&serde_json::json!("ctc_render"))
);
assert_eq!(
event.pointer("/item/input"),
Some(&serde_json::json!("hello"))
);
assert_eq!(event.get("metadata"), Some(&business));
assert_eq!(event.pointer("/item/metadata"), Some(&business));
}
#[test]
fn decoder_should_reject_custom_arguments_beyond_the_buffer_limit() {
let request = tool_request(serde_json::json!({
"model": "client",
"input": "render",
"tools": [{"type": "custom", "name": "render"}]
}));
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.6", &request);
let added = encode_sse_event(
"response.output_item.added",
&serde_json::json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {"id": "fc_render", "type": "function_call", "call_id": "call_render", "name": "render"}
}).to_string(),
);
decoder.push(added.as_bytes()).expect("custom item added");
// 分帧累计也必须受限,不能只限制单个 SSE frame。
for size in [1 << 20, 1] {
let delta = encode_sse_event(
"response.function_call_arguments.delta",
&serde_json::json!({
"type": "response.function_call_arguments.delta",
"item_id": "fc_render",
"call_id": "call_render",
"output_index": 0,
"delta": "x".repeat(size)
})
.to_string(),
);
let result = decoder.push(delta.as_bytes());
if size == 1 {
assert!(result.is_err(), "custom arguments must not grow past 1 MiB");
} else {
assert!(result.expect("arguments at the limit").is_empty());
}
}
}
#[test]
fn decoder_should_normalize_schema_integer_arguments_and_resequence_split_events() {
let request = tool_request(serde_json::json!({
@@ -2,8 +2,8 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use provider_xai::{
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError,
GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GROK_SUBSCRIPTION_URL, GrokBillingClient,
GrokBillingError, GrokBillingRequest, GrokBillingTransport, GrokBillingTransportError,
GrokBillingTransportFuture, GrokBillingTransportResponse, GrokCatalogCapabilityEvidence,
GrokHeaderValue, GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogTransport, GrokModelCatalogTransportError,
@@ -194,6 +194,71 @@ fn billing_parser_should_preserve_unknown_provider_fields() {
assert!(snapshot.document().get("futureTopLevel").is_some());
}
#[tokio::test]
async fn subscription_query_distinguishes_confirmed_free_paid_and_unknown() {
for (body, expected) in [
(
r#"{"userId":"u1","principalType":"User","teamId":null,"organizationId":null,"subscriptionTier":null}"#,
Some("Free"),
),
(
r#"{"userId":"u1","subscriptionTier":" SuperGrokPro "}"#,
Some("SuperGrokPro"),
),
(
r#"{"userId":"u1","subscriptionTier":"FutureTier"}"#,
Some("FutureTier"),
),
(
r#"{"userId":"u1","principalType":"Team","subscriptionTier":null}"#,
None,
),
(
r#"{"userId":"u1","principalType":"User","teamId":"team1","organizationId":null,"subscriptionTier":null}"#,
None,
),
(r#"{"userId":"u1","subscriptionTier":null}"#, None),
(r#"{"userId":"u1"}"#, None),
(r#"{"userId":"u1","subscriptionTier":" "}"#, None),
] {
let transport = Arc::new(CapturingBillingTransport::success(body));
let actual = GrokBillingClient::new(transport.clone())
.fetch_subscription(&session(None))
.await
.expect("subscription response");
assert_eq!(actual.as_deref(), expected, "{body}");
let request = transport.request.lock().expect("captured request");
assert_eq!(
request
.as_ref()
.expect("subscription request")
.endpoint()
.as_str(),
GROK_SUBSCRIPTION_URL
);
}
}
#[tokio::test]
async fn subscription_query_rejects_malformed_or_oversized_user_responses() {
for body in [
r#"{}"#.to_owned(),
r#"{"userId":"","subscriptionTier":"Free"}"#.to_owned(),
r#"{"userId":"u1","subscriptionTier":1}"#.to_owned(),
r#"{"userId":"u1","subscriptionTier":"Free\n"}"#.to_owned(),
serde_json::json!({"userId":"u1","subscriptionTier":"a".repeat(513)}).to_string(),
" ".repeat(MAX_GROK_BILLING_BYTES + 1),
] {
let transport = Arc::new(CapturingBillingTransport::success(body));
assert!(
GrokBillingClient::new(transport)
.fetch_subscription(&session(None))
.await
.is_err()
);
}
}
#[test]
fn billing_parser_should_accept_credits_and_legacy_shapes() {
for body in [
@@ -865,6 +865,72 @@ fn endpoint_policy_should_reject_private_and_documentation_addresses() {
}
}
#[tokio::test]
async fn subscription_transport_uses_the_official_query_and_rejects_redirects() {
for (status, expected) in [(200, Some("Free")), (302, None)] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/user"))
.and(query_param("include", "subscription"))
.and(wiremock::matchers::header("authorization", "Bearer access-token"))
.and(wiremock::matchers::header("X-XAI-Token-Auth", "xai-grok-cli"))
.respond_with(ResponseTemplate::new(status)
.insert_header("location", format!("{}/redirected", server.uri()))
.set_body_json(json!({"userId":"user-id","principalType":"User","teamId":null,"organizationId":null,"subscriptionTier":null})))
.expect(1).mount(&server).await;
Mock::given(path("/redirected"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).unwrap();
let transport = Arc::new(
ReqwestGrokModelCatalogTransport::new(loopback_endpoint_policy(&origin)).unwrap(),
);
let session = GrokModelCatalogSession::new(
SecretValue::new("access-token"),
SecretValue::new("user-id"),
None,
crate::support::xai_wire_profile(),
)
.unwrap();
let result = GrokBillingClient::new(transport)
.fetch_subscription(&session)
.await;
if let Some(expected) = expected {
assert_eq!(result.unwrap().as_deref(), Some(expected));
} else {
assert!(result.is_err());
}
}
}
#[test]
fn subscription_endpoint_policy_accepts_only_the_fixed_official_resource() {
let policy = OfficialGrokEndpointPolicy;
assert!(
policy
.route_billing(&Url::parse(provider_xai::GROK_SUBSCRIPTION_URL).unwrap())
.is_some()
);
for endpoint in [
"http://cli-chat-proxy.grok.com/v1/user?include=subscription",
"https://other.example/v1/user?include=subscription",
"https://cli-chat-proxy.grok.com/v1/user",
"https://cli-chat-proxy.grok.com/v1/user?include=profile",
"https://cli-chat-proxy.grok.com/v1/user?include=subscription&extra=1",
"https://cli-chat-proxy.grok.com/v1/user?include=subscription#include",
"https://user:pass@cli-chat-proxy.grok.com/v1/user?include=subscription",
] {
assert!(
policy
.route_billing(&Url::parse(endpoint).unwrap())
.is_none(),
"{endpoint}"
);
}
}
#[test]
fn fake_ip_system_result_should_use_public_trusted_fallback() {
let calls = Cell::new(0_u8);
@@ -91,10 +91,7 @@ fn encoder_should_strip_all_client_metadata_before_grok_build() {
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/client_metadata"), None);
assert_eq!(
body.pointer("/metadata/application_tag"),
Some(&json!("keep-this"))
);
assert_eq!(body.pointer("/metadata/application_tag"), None);
let request = raw_request(json!({
"model": "client-model",
@@ -133,16 +130,14 @@ fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"reasoning": {"effort": "max"},
"reasoning_effort": "extra_high",
"reasoningEffort": "minimal"
"reasoning": {"effort": "xhigh"}
}));
let encoded = GrokResponsesRequest::encode(&request, "xai/grok-4.6-latest", &client_key())
.expect("aliased request");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/model"), Some(&json!("grok-4.6")));
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
assert_eq!(body.pointer("/reasoning_effort"), Some(&json!("high")));
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("xhigh")));
assert_eq!(body.pointer("/reasoning_effort"), None);
assert_eq!(body.pointer("/reasoningEffort"), None);
let request = raw_request(json!({
@@ -236,7 +231,6 @@ fn encoder_should_strip_grok_unsupported_fields() {
"/external_web_access",
"/metadata/external_web_access",
"/tools/0/external_web_access",
"/tools/0/parameters/properties/q/external_web_access",
] {
assert_eq!(body.pointer(pointer), None, "field survived at {pointer}");
}
@@ -364,10 +358,7 @@ fn account_identity_should_be_removed_without_touching_prompt_content() {
body.pointer("/input/0/content/0/text"),
Some(&json!("account_id and x-userid are ordinary prompt text"))
);
assert_eq!(
body.pointer("/metadata/application_tag"),
Some(&json!("preserve-me"))
);
assert_eq!(body.pointer("/metadata/application_tag"), None);
}
#[test]
@@ -647,7 +638,7 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("apply_patch")));
assert_eq!(
body.pointer("/tools/0/parameters/required"),
Some(&json!(["patch"]))
Some(&json!(["input"]))
);
assert_eq!(body.pointer("/tools/1/name"), Some(&json!("read_file")));
assert_eq!(body.pointer("/tools/2"), Some(&json!({"type": "x_search"})));
@@ -796,21 +787,19 @@ fn soft_session_should_follow_the_first_user_anchor() {
}
#[test]
fn response_format_and_reasoning_parts_should_match_build_wire_shape() {
fn current_text_format_and_reasoning_parts_should_match_build_wire_shape() {
let request = raw_request(json!({
"model": "client",
"input": [{
"type": "reasoning",
"content": [{"text": "summary"}]
}],
"response_format": {
"text": {"format": {
"type": "json_schema",
"json_schema": {
"name": "answer",
"strict": true,
"schema": {"type": "object"}
}
}
"name": "answer",
"strict": true,
"schema": {"type": "object"}
}}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
@@ -847,7 +836,7 @@ fn tool_declarations_should_flatten_and_emulate_codex_tool_shapes() {
},
{"type": "custom", "name": "render", "format": {"type": "text"}},
{"type": "apply_patch"},
{"type": "local_shell"},
{"type": "shell", "environment": {"type": "local"}},
{
"type": "function",
"name": "deferred_lookup",
@@ -891,7 +880,7 @@ fn tool_declarations_should_flatten_and_emulate_codex_tool_shapes() {
}
#[test]
fn custom_apply_patch_declaration_should_use_patch_parameter() {
fn custom_apply_patch_declaration_should_use_the_shared_input_parameter() {
let request = raw_request(json!({
"model": "client",
"input": "edit",
@@ -920,18 +909,18 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
assert_eq!(
body.pointer("/tools/0/parameters/properties"),
Some(&json!({"patch": {"type": "string"}}))
Some(&json!({"input": {"type": "string"}}))
);
assert_eq!(
body.pointer("/tools/0/parameters/required"),
Some(&json!(["patch"]))
);
assert_eq!(
body.pointer("/tools/0/description"),
Some(&json!(
"The apply_patch tool edits files using Codex patch format. Provide the complete raw patch text in the patch string field."
))
Some(&json!(["input"]))
);
let description = body
.pointer("/tools/0/description")
.and_then(Value::as_str)
.expect("description");
assert!(description.contains("This is a FREEFORM tool"));
assert!(description.contains("input string field"));
assert_eq!(
body.pointer("/tools/1/parameters/properties"),
Some(&json!({"input": {"type": "string"}}))
@@ -981,7 +970,7 @@ fn history_should_rebuild_codex_calls_outputs_shell_and_private_fields() {
"tools": [
{"type": "custom", "name": "render"},
{"type": "apply_patch"},
{"type": "local_shell"}
{"type": "shell", "environment": {"type": "local"}}
],
"input": [
{"type": "message", "role": "assistant", "id": "msg_1", "content": [
@@ -992,8 +981,8 @@ fn history_should_rebuild_codex_calls_outputs_shell_and_private_fields() {
{"type": "custom_tool_call_output", "call_id": "custom_1", "output": {"ok": true}, "status": "completed"},
{"type": "apply_patch_call", "call_id": "patch_1", "operation": {"type": "delete_file", "path": "old.txt"}},
{"type": "apply_patch_call_output", "call_id": "patch_1", "status": "completed", "output": "deleted"},
{"type": "local_shell_call", "call_id": "shell_1", "action": {"type": "exec", "command": ["printf", "%s", "a b"], "working_directory": "/tmp"}},
{"type": "local_shell_call_output", "call_id": "shell_1", "status": "failed", "output": "failure"},
{"type": "shell_call", "call_id": "shell_1", "action": {"commands": ["cd /tmp && printf %s 'a b'"]}},
{"type": "shell_call_output", "call_id": "shell_1", "output": [{"stdout":"failure","stderr":"","outcome":{"type":"exit","exit_code":1}}]},
{"type": "reasoning", "id": "reason_1", "status": "completed", "summary": [{"type": "summary_text", "text": "brief", "phase": "drop"}]},
{"type": "future_codex_item", "id": "future_1", "status": "completed"}
]
@@ -1161,7 +1150,7 @@ fn history_sanitizer_should_only_strip_known_grok_injection_sites() {
}
#[test]
fn custom_apply_patch_history_should_wrap_raw_input_in_patch_field() {
fn custom_apply_patch_history_should_use_the_shared_input_wrapper() {
let patch = concat!(
"*** Begin Patch\n",
"*** Update File: src/lib.rs\n",
@@ -1197,7 +1186,7 @@ fn custom_apply_patch_history_should_wrap_raw_input_in_patch_field() {
.and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
.expect("function arguments");
assert_eq!(arguments, json!({"patch": patch}));
assert_eq!(arguments, json!({"input": patch}));
assert_eq!(
body.pointer("/input/1/type"),
Some(&json!("function_call_output"))
@@ -1205,41 +1194,18 @@ fn custom_apply_patch_history_should_wrap_raw_input_in_patch_field() {
}
#[test]
fn compaction_history_should_become_plaintext_user_continuation_in_place() {
fn compact_history_without_summary_should_preserve_ciphertext_as_reasoning() {
let request = raw_request(json!({
"model": "client",
"input": [
{"type": "message", "role": "user", "content": "before compaction"},
{
"type": "compaction",
"encrypted_content": "Repository state and pending work."
},
{"type": "message", "role": "user", "content": "continue"}
]
"input": [{"type": "compaction", "encrypted_content": "grok-encrypted-state"}]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("compaction continuation");
let body = Value::Object(encoded.body().clone());
let encoded = GrokResponsesRequest::encode(&request, "grok-4.6", &client_key())
.expect("compaction ciphertext");
assert_eq!(
body.pointer("/input"),
Some(&json!([
{"type": "message", "role": "user", "content": "before compaction"},
{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": concat!(
"This session is being continued from a previous conversation that ran out of context. ",
"The summary below covers the earlier portion of the conversation.\n\n",
"Repository state and pending work."
)
}]
},
{"type": "message", "role": "user", "content": "continue"}
]))
encoded.body().get("input"),
Some(&json!([{
"type": "reasoning", "summary": [], "encrypted_content": "grok-encrypted-state"
}]))
);
}
@@ -1320,7 +1286,7 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
"model": "client",
"tools": [{"type": "tool_search", "execution": "client"}],
"input": [
{"type": "tool_search_call", "execution": "client", "call_id": "search_1", "arguments": {"goal": "shipping"}},
{"type": "tool_search_call", "id": "tsc_search", "execution": "client", "call_id": "search_1", "arguments": {"goal": "shipping"}},
{"type": "tool_search_output", "execution": "client", "call_id": "search_1", "tools": [{
"type": "namespace",
"name": "shipping",
@@ -1341,6 +1307,7 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
body.pointer("/input/1/type"),
Some(&json!("function_call_output"))
);
assert_eq!(body.pointer("/input/0/id"), Some(&json!("fc_search")));
assert_eq!(
body.pointer("/tools/0/name"),
Some(&json!("shipping__track"))
@@ -1414,3 +1381,75 @@ fn ambiguous_tool_contracts_should_fail_before_upstream_io() {
);
}
}
#[test]
fn retired_wire_formats_are_rejected_without_fallback() {
for field in ["response_format", "reasoning_effort", "reasoningEffort"] {
let mut body = json!({"input":"hello"});
body[field] = json!("high");
assert!(
GrokResponsesRequest::encode(&raw_request(body), "grok-4.6", &client_key()).is_err()
);
}
for kind in ["local_shell_call", "local_shell_call_output"] {
assert!(
GrokResponsesRequest::encode(
&raw_request(json!({"input":[{"type":kind,"call_id":"old"}]})),
"grok-4.6",
&client_key()
)
.is_err()
);
}
}
#[test]
fn tool_schema_properties_survive_protocol_field_sanitization() {
let schema = json!({"type":"object","properties":{"external_web_access":{"type":"boolean"},"q":{"type":"string","external_web_access":true}}});
let encoded = GrokResponsesRequest::encode(
&raw_request(json!({
"input":"hello", "tools":[{"type":"function","name":"lookup","parameters":schema}]
})),
"grok-4.6",
&client_key(),
)
.unwrap();
assert_eq!(encoded.body()["tools"][0]["parameters"], schema);
}
#[test]
fn custom_tool_history_retypes_item_ids_without_changing_call_ids() {
let encoded = GrokResponsesRequest::encode(&raw_request(json!({
"tools":[{"type":"custom","name":"render"}],
"input":[{"type":"custom_tool_call","id":"ctc_roundtrip","call_id":"call_roundtrip","name":"render","input":"data"}]
})), "grok-4.6", &client_key()).unwrap();
assert_eq!(encoded.body()["input"][0]["id"], "fc_roundtrip");
assert_eq!(encoded.body()["input"][0]["call_id"], "call_roundtrip");
}
#[test]
fn custom_patch_lowering_should_preserve_description_and_input_grammar() {
let description = "Use this tool to edit files; preserve existing unrelated changes.";
let grammar =
"start: begin body end\nbegin: \"*** Begin Patch\" NEWLINE\nend: \"*** End Patch\"";
let request = raw_request(json!({
"model": "client", "input": "edit",
"tools": [{"type": "custom", "name": "apply_patch", "description": description,
"format": {"type": "grammar", "syntax": "lark", "definition": grammar}}]
}));
let encoded =
GrokResponsesRequest::encode(&request, "grok-4.6", &client_key()).expect("custom tool");
let lowered = encoded
.body()
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| tools.first())
.expect("tool");
let text = lowered
.get("description")
.and_then(Value::as_str)
.expect("description");
assert!(text.contains(description));
assert!(text.contains(grammar));
assert!(lowered.get("format").is_none());
}
+2
View File
@@ -1,3 +1,5 @@
b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.sql
78c427c17d8b5759405f1629a80ceb624012cb6b31371b762078b000266d86c6 0002_client_key_budgets.sql
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
@@ -0,0 +1,17 @@
-- 移除人工核账:历史未取得费用的请求按零结算,已记录金额保持不变。
-- 迁移在监听请求前执行;遗留未完成记录不再阻断 Key。
update client_key_charge_events
set state = 'settled',
amount_usd = coalesce(amount_usd, 0),
completed_at = coalesce(completed_at, least(deadline_at, now()));
-- 费用表只保存已完成的幂等结算,不再维护预扣费和待核账状态。
drop index client_key_charge_events_unresolved;
alter table client_key_charge_events
drop column state,
drop column started_at,
drop column deadline_at,
drop column reconciled_at,
drop column reconciliation_reason,
alter column amount_usd set not null,
alter column completed_at set not null;
@@ -0,0 +1,26 @@
create table outbound_proxies (
id text primary key,
name text not null check (char_length(name) between 1 and 100),
proxy_url text not null,
revision bigint not null default 1 check (revision > 0),
last_test_at timestamptz,
last_test_success boolean,
last_test_latency_ms bigint,
last_test_ip text,
last_test_message text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table provider_accounts add column outbound_proxy_id text
references outbound_proxies(id) on delete restrict;
create index provider_accounts_outbound_proxy_id_idx on provider_accounts(outbound_proxy_id);
insert into outbound_proxies (id, name, proxy_url)
select 'proxy_' || gen_random_uuid()::text,
'Imported proxy ' || row_number() over (order by proxy_url), proxy_url
from (select distinct outbound_proxy_url as proxy_url from provider_accounts
where outbound_proxy_url is not null) existing;
update provider_accounts a set outbound_proxy_id = p.id
from outbound_proxies p where p.proxy_url = a.outbound_proxy_url;

Some files were not shown because too many files have changed in this diff Show More