mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f05a4481 | ||
|
|
bb4d178b5d | ||
|
|
73487fd98b | ||
|
|
66fd09eba6 | ||
|
|
bd152dec00 | ||
|
|
7090dca502 | ||
|
|
029f3124bf | ||
|
|
840707e5a1 | ||
|
|
704b652f86 | ||
|
|
2b98839c29 | ||
|
|
90dd5b5a57 | ||
|
|
5ad9378b33 | ||
|
|
4a413f5bb7 | ||
|
|
0bab566d04 | ||
|
|
9b19cf083a |
@@ -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/*
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
|
||||
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
|
||||
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
|
||||
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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("a),
|
||||
),
|
||||
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("a),
|
||||
),
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user