Compare commits

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:40:00 +08:00
54 changed files with 2020 additions and 2163 deletions
+31 -9
View File
@@ -88,6 +88,13 @@ jobs:
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
@@ -643,15 +650,10 @@ jobs:
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
- name: Prepare release notes
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
@@ -672,6 +674,26 @@ jobs:
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
@@ -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,
}
@@ -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(
@@ -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
@@ -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>,
@@ -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"]
@@ -1,4 +1,4 @@
//! Durable per-key USD budgets, independent of best-effort request observations.
//! 按 Key 持久化 USD 限额与已取得费用,不依赖尽力写入的请求观测。
use std::time::SystemTime;
@@ -28,21 +28,14 @@ pub struct ClientBudgetStatus {
pub weekly_used_usd: Decimal,
pub daily_resets_at: Option<SystemTime>,
pub weekly_resets_at: Option<SystemTime>,
pub unresolved_requests: u64,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetAdmission {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
pub deadline_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetCharge {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
/// None means the request may have incurred an unknown charge, never zero.
pub amount_usd: Option<Decimal>,
/// 已取得的 USD 费用,包含重试;缺少费用的尝试按零累计。
pub amount_usd: Decimal,
pub completed_at: SystemTime,
}
@@ -51,9 +44,9 @@ pub struct ClientBudgetCharge {
pub struct ClientBudgetError;
pub trait ClientBudgetPort: Send + Sync {
/// Persist a pending charge before any upstream work; read the current policy atomically.
fn admit(&self, request: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>>;
/// 原子检查当前限额与已用金额,不创建预扣费或待结算记录。
fn admit(&self, key_id: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>>;
/// Idempotent by gateway request ID. Failed writes leave the durable pending charge intact.
/// 按网关请求 ID 幂等累计已取得费用;写入失败由 Store 重试。
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>>;
}
@@ -167,8 +167,7 @@ where
request_id,
client_api_key_ref,
observation: ResponseObservation::new(timing_started_at),
budget_prior_attempts_usd: Some(Decimal::ZERO),
budget_attempt_send_state: UpstreamSendState::NotSent,
budget_prior_attempts_usd: Decimal::ZERO,
budget_attempt_already_counted: false,
trace,
deadline,
@@ -258,8 +257,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
request_id: ModelRequestId,
client_api_key_ref: crate::policy::ClientApiKeyId,
observation: ResponseObservation,
budget_prior_attempts_usd: Option<Decimal>,
budget_attempt_send_state: UpstreamSendState,
budget_prior_attempts_usd: Decimal,
budget_attempt_already_counted: bool,
trace: TraceContext,
deadline: SystemTime,
@@ -486,30 +484,31 @@ where
#[must_use]
pub fn budget_charge(&self) -> super::budget::ClientBudgetCharge {
// 超出数据库可表示范围时保留最大金额,避免溢出后误记为零。
let amount_usd = self
.budget_prior_attempts_usd
.zip(self.budget_attempt_usd())
.and_then(|(prior, current)| prior.checked_add(current));
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
super::budget::ClientBudgetCharge {
key_id: self.client_api_key_ref.clone(),
request_id: self.request_id.clone(),
amount_usd,
completed_at: self.finalized_at.unwrap_or_else(SystemTime::now),
}
}
fn budget_attempt_usd(&self) -> Option<Decimal> {
fn budget_attempt_usd(&self) -> Decimal {
if self.budget_attempt_already_counted {
return Some(Decimal::ZERO);
return Decimal::ZERO;
}
// Key 只累计已取得的 USD 费用;缺少费用的请求按零结算。
// 发送状态仍用于判断重放是否安全并保留在诊断中,不能据此推断存在欠费。
self.observation
.cost
.total()
.filter(|money| money.currency().as_str() == "USD")
.map(crate::metering::Money::amount)
.or_else(|| {
(self.budget_attempt_send_state == UpstreamSendState::NotSent)
.then_some(Decimal::ZERO)
})
.unwrap_or(Decimal::ZERO)
}
/// 返回截至当前已完成的实际上游调用结果。
@@ -786,8 +785,6 @@ where
}),
);
let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone());
// Cancellation while a provider is opening cannot prove the request was never sent.
self.budget_attempt_send_state = UpstreamSendState::Ambiguous;
let stream = match poll_provider(
provider,
provider_request,
@@ -811,7 +808,6 @@ where
ProviderBoundary::Result(result) => match *result {
Ok(stream) => stream,
Err(error) => {
self.budget_attempt_send_state = error.send_state();
record_trace_error(&attempt_trace, &error);
if self.prepare_unavailable_native_continuation_replay(&error) {
return Ok(Some(PullOutcome::AttemptDiscarded));
@@ -1068,7 +1064,6 @@ where
.await;
}
current.send_observed = true;
self.budget_attempt_send_state = UpstreamSendState::Sent;
self.send_state_watermark = UpstreamSendState::Sent;
}
}
@@ -1108,7 +1103,6 @@ where
error.send_state()
};
let send_state = self.raise_send_watermark(attempt_send_state);
self.budget_attempt_send_state = attempt_send_state;
if self.request_persisted {
best_effort_store_write(
"mark_send_state",
@@ -1365,12 +1359,11 @@ where
}
fn reset_uncommitted_observations(&mut self) {
// Response facts are attempt-local; the key must also pay for discarded attempts.
// 响应观测按尝试隔离;被丢弃尝试中已取得的费用仍计入 Key。
self.budget_prior_attempts_usd = self
.budget_prior_attempts_usd
.zip(self.budget_attempt_usd())
.and_then(|(prior, current)| prior.checked_add(current));
self.budget_attempt_send_state = UpstreamSendState::NotSent;
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
self.budget_attempt_already_counted = false;
self.observation.reset_for_attempt();
self.upstream_complete = false;
@@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::engine::admission::{
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
};
use crate::engine::budget::{ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetPort};
use crate::engine::budget::{ClientBudgetCharge, ClientBudgetPort};
use crate::engine::continuation::{
ContinuationBinding, NativeContinuationPin, NativeContinuationPort,
NativeContinuationStoreErrorKind, PreviousResponseId,
@@ -523,13 +523,7 @@ impl DefaultExecutionService {
model_request_id: request_id.clone(),
};
if let Some(budget) = &self.budget
&& let Err(error) = budget
.admit(ClientBudgetAdmission {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
deadline_at,
})
.await
&& let Err(error) = budget.admit(client.policy.key_id().clone()).await
{
admission.release().await;
return Err(error);
@@ -586,8 +580,9 @@ impl DefaultExecutionService {
settle_budget(
budget.as_ref(),
ClientBudgetCharge {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
amount_usd: Some(crate::metering::Decimal::ZERO),
amount_usd: crate::metering::Decimal::ZERO,
completed_at: SystemTime::now(),
},
)
@@ -966,7 +961,7 @@ struct AdmissionLease {
async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge) {
if let Err(error) = port.settle(charge).await {
tracing::error!(%error, "Client budget settlement pending; durable admission requires reconciliation");
tracing::error!(%error, "Client budget settlement failed; storage will retry on the next request");
}
}
@@ -14,6 +14,8 @@ pub struct Decimal(u128);
impl Decimal {
pub const ZERO: Self = Self(0);
/// 数据库可表示的最大非负金额。
pub const MAX: Self = Self(MAX_SCALED_DECIMAL);
/// 从按十位小数缩放的整数创建。
///
@@ -2159,18 +2159,23 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
assert_eq!(state.finalizations[0].cost_source, CostSource::Unavailable);
assert_eq!(state.finalizations[0].cost_ticks, None);
assert_eq!(
session.budget_charge().amount_usd,
None,
"unknown final usage cannot erase a charged retry"
session.budget_charge().amount_usd.scaled(),
999,
"missing final usage cannot erase a charged retry"
);
}
#[test]
fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
fn key_budget_accumulates_only_observed_retry_costs() {
for (first_cost, first_send, expected) in [
(Some(999), UpstreamSendState::Sent, Some(1122)),
(None, UpstreamSendState::Sent, None),
(None, UpstreamSendState::NotSent, Some(123)),
(Some(999), UpstreamSendState::Sent, 1122),
(
Some(gateway_core::metering::Decimal::MAX.scaled()),
UpstreamSendState::Sent,
gateway_core::metering::Decimal::MAX.scaled(),
),
(None, UpstreamSendState::Sent, 123),
(None, UpstreamSendState::NotSent, 123),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
@@ -2225,7 +2230,7 @@ fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
block_on(session.collect_uncommitted()).unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
let charge = session.budget_charge();
assert_eq!(charge.amount_usd.map(|amount| amount.scaled()), expected);
assert_eq!(charge.amount_usd.scaled(), expected);
assert_eq!(
session.budget_charge().completed_at,
charge.completed_at,
@@ -2234,6 +2239,67 @@ fn key_budget_accumulates_retries_and_preserves_unknown_attempts() {
}
}
#[test]
fn key_budget_settles_post_send_failures_from_observed_cost_without_replaying() {
for send_state in [UpstreamSendState::Sent, UpstreamSendState::Ambiguous] {
for cost in [None, Some(123)] {
for committed in [false, true] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut items = vec![Ok(GatewayEvent::Started(ResponseMeta::new(
"response-interrupted",
"gpt-5",
)))];
if let Some(cost) = cost {
items.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
items.push(Err(ProviderError::new(
ProviderErrorKind::Transport,
send_state,
)));
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items,
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
if committed {
block_on(session.next_event()).unwrap().unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
loop {
match block_on(session.next_event()) {
Ok(Some(_)) => {}
Err(EngineError::Provider(_)) => break,
other => panic!("expected terminal transport error, got {other:?}"),
}
}
} else {
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
}
assert!(session.is_finalized());
assert_eq!(
session.budget_charge().amount_usd.scaled(),
cost.unwrap_or(0),
"send={send_state:?}, committed={committed}, cost={cost:?}"
);
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
}
}
}
}
#[test]
fn pre_commit_failure_excludes_account_and_retries_same_target() {
let operation = generate_operation();
@@ -2812,11 +2878,8 @@ fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire()
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
assert_eq!(
session
.budget_charge()
.amount_usd
.map(|amount| amount.scaled()),
Some(500),
session.budget_charge().amount_usd.scaled(),
500,
"restoring a discarded failure must not charge its usage twice"
);
let state = store.state.lock().expect("store lock");
@@ -2956,6 +3019,7 @@ fn ambiguous_send_state_stops_retry() {
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.send_state, UpstreamSendState::Ambiguous);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
assert_eq!(finalization.attempt_count, 1);
assert!(!finalization.committed);
assert!(finalization.latency_ms.is_some());
@@ -3430,6 +3494,7 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
assert_eq!(state.commits, 0);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Cancelled);
assert!(!state.finalizations[0].committed);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
}
#[test]
@@ -7,9 +7,7 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::budget::{
ClientBudgetAdmission, ClientBudgetCharge, ClientBudgetError, ClientBudgetPort,
};
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
#[derive(Default)]
@@ -51,7 +49,7 @@ struct Budget {
}
impl ClientBudgetPort for Budget {
fn admit(&self, _: ClientBudgetAdmission) -> BoxFuture<'_, Result<(), GatewayError>> {
fn admit(&self, _: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async {
assert!(self.active.load(Ordering::SeqCst));
if self.reject {
@@ -230,7 +228,7 @@ fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all
assert!(
charges
.iter()
.all(|charge| charge.amount_usd == Some(gateway_core::metering::Decimal::ZERO))
.all(|charge| charge.amount_usd == gateway_core::metering::Decimal::ZERO)
);
}
@@ -1,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,
@@ -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;
}
@@ -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",
}
}
@@ -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());
}
+1 -1
View File
@@ -1140,7 +1140,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")
+25 -24
View File
@@ -56,30 +56,31 @@ pub use transport::profile::{
GrokCliReleaseStatus, GrokCliReleaseTransport,
};
pub use transport::{
GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient,
GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport,
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
GrokBillingTransportResponse, GrokCanonicalDecoder, GrokCatalogApiBackend,
GrokCatalogCapabilities, GrokCatalogCapabilityEvidence, GrokCatalogLimits, GrokCatalogMetadata,
GrokCatalogModel, GrokCatalogReasoningEffort, GrokClientIdentity, GrokCompactionDecodeError,
GrokCompactionRequest, GrokCompactionSummaryDecoder, GrokCredentialFailure,
GrokCredentialFeedbackFuture, GrokDnsResolutionError, GrokDnsResolutionPlan,
GrokDnsResolutionPolicy, GrokEndpointPolicy, GrokHeader, GrokHeaderValue,
GrokInferenceChunkStream, GrokInferenceClientCacheStatus, GrokInferenceDnsObservation,
GrokInferenceDnsSource, GrokInferenceRequest, GrokInferenceResponse, GrokInferenceTransport,
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokInferenceTransportMetrics, GrokModelCatalogClient, GrokModelCatalogError,
GrokModelCatalogRequest, GrokModelCatalogSession, GrokModelCatalogSessionError,
GrokModelCatalogSnapshot, GrokModelCatalogTransport, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, GrokProviderConfigError, GrokProviderTransport,
GrokRequestEncodeError, GrokReqwestTransportBuildError, GrokResponsesRequest,
GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError, GrokSessionLeaseGuard,
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OfficialGrokEndpointPolicy,
ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport,
SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers, grok_billing_breakdown,
grok_billing_breakdown_with_tier, parse_grok_billing, parse_grok_model_catalog,
GROK_BILLING_URL, GROK_CLI_BASE_URL, GROK_MODEL_CATALOG_URL, GROK_SUBSCRIPTION_URL,
GrokBillingClient, GrokBillingError, GrokBillingRequest, GrokBillingSnapshot,
GrokBillingTransport, GrokBillingTransportError, GrokBillingTransportErrorKind,
GrokBillingTransportFuture, GrokBillingTransportResponse, GrokCanonicalDecoder,
GrokCatalogApiBackend, GrokCatalogCapabilities, GrokCatalogCapabilityEvidence,
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokCatalogReasoningEffort,
GrokClientIdentity, GrokCompactionDecodeError, GrokCompactionRequest,
GrokCompactionSummaryDecoder, GrokCredentialFailure, GrokCredentialFeedbackFuture,
GrokDnsResolutionError, GrokDnsResolutionPlan, GrokDnsResolutionPolicy, GrokEndpointPolicy,
GrokHeader, GrokHeaderValue, GrokInferenceChunkStream, GrokInferenceClientCacheStatus,
GrokInferenceDnsObservation, GrokInferenceDnsSource, GrokInferenceRequest,
GrokInferenceResponse, GrokInferenceTransport, GrokInferenceTransportError,
GrokInferenceTransportErrorKind, GrokInferenceTransportFuture, GrokInferenceTransportMetrics,
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokProviderConfigError,
GrokProviderTransport, GrokRequestEncodeError, GrokReqwestTransportBuildError,
GrokResponsesRequest, GrokSessionAffinityKey, GrokSessionBinding, GrokSessionDataError,
GrokSessionLeaseGuard, GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError,
GrokSessionSelectorFuture, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES,
OfficialGrokEndpointPolicy, ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport,
ReqwestOAuthTransport, SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers,
grok_billing_breakdown, grok_billing_breakdown_with_tier, parse_grok_billing,
parse_grok_model_catalog,
};
/// xAI 初始化后交给组装根的最小能力集。
@@ -7,6 +7,8 @@ pub(super) struct XaiSessionState {
pub(super) account_id: String,
pub(super) session_id: Option<String>,
pub(super) transcript: Vec<XaiReplayItem>,
pub(super) instructions: Option<Value>,
pub(super) response_stored: bool,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -20,6 +22,8 @@ pub(super) enum XaiReplayItem {
pub(super) struct GrokSessionCapture {
pub(super) previous: Option<XaiSessionState>,
pub(super) request_input: Vec<Value>,
pub(super) instructions: Option<Value>,
pub(super) response_stored: bool,
pub(super) account_id: String,
pub(super) session_id: Option<String>,
pub(super) output_items: BTreeMap<u32, Value>,
@@ -110,6 +114,15 @@ pub(super) fn apply_continuation(
if !pin.matches(&provider, account) {
return Err(invalid_continuation());
}
if previous_session.is_some_and(|state| !state.response_stored)
|| request.instructions()
!= previous_session.and_then(|state| state.instructions.as_ref())
{
let previous = previous_session.ok_or_else(invalid_continuation)?;
return apply_replay(request, previous, account, current_input);
}
// Grok 原生续接继承首轮指令,不允许同时提交 instructions。
request.clear_instructions();
request.set_previous_response_id(Some(pin.upstream_response_id().as_str().to_owned()));
Ok(())
}
@@ -120,18 +133,27 @@ pub(super) fn apply_continuation(
{
return Err(invalid_continuation());
}
let mut input = replay_input_for_account(previous, account.as_str(), true);
input.reserve(current_input.len());
input.extend(current_input.iter().cloned());
request.set_replay_input(input).map_err(map_request_error)?;
request.set_previous_response_id(None);
request.inherit_session(None);
Ok(())
apply_replay(request, previous, account, current_input)
}
ContinuationAttempt::None => Err(invalid_continuation()),
}
}
fn apply_replay(
request: &mut GrokResponsesRequest,
previous: &XaiSessionState,
account: &gateway_core::account::ProviderAccountId,
current_input: &[Value],
) -> Result<(), ProviderError> {
let mut input = replay_input_for_account(previous, account.as_str(), true);
input.reserve(current_input.len());
input.extend(current_input.iter().cloned());
request.set_replay_input(input).map_err(map_request_error)?;
request.set_previous_response_id(None);
request.inherit_session(None);
Ok(())
}
pub(super) fn replay_input_for_account(
state: &XaiSessionState,
account_id: &str,
@@ -261,6 +283,8 @@ pub(super) fn attach_xai_session_update(
account_id: capture.account_id,
session_id: capture.session_id,
transcript,
instructions: capture.instructions,
response_stored: capture.response_stored,
};
if let Some(update) = encode_xai_session_state(state)? {
events[terminal_index].attach_session_update(update);
@@ -230,6 +230,7 @@ impl GrokBuildProvider {
)
.map_err(|_| protocol_not_sent())?;
let request_input = upstream_request.input_items();
let instructions = upstream_request.instructions().cloned();
if let Some(previous) = previous_session.as_ref() {
upstream_request.inherit_session(previous.session_id.as_deref());
}
@@ -293,6 +294,12 @@ impl GrokBuildProvider {
.then(|| GrokSessionCapture {
previous: previous_session,
request_input,
instructions,
response_stored: upstream_request
.body()
.get("store")
.and_then(Value::as_bool)
== Some(true),
account_id: selected.account_id().as_str().to_owned(),
session_id: upstream_request.session_id().map(str::to_owned),
output_items: BTreeMap::new(),
@@ -294,10 +294,10 @@ impl GrokCanonicalDecoder {
continue;
}
let Some(event_type) = body_type.or(event.event.as_deref()) else {
let Ok(transformed) = self.response_transform.rewrite_stream_event("", value)
else {
continue;
};
let transformed = self
.response_transform
.rewrite_stream_event("", value)
.map_err(|_| protocol_error_marker())?;
for (index, transformed) in transformed.into_iter().enumerate() {
let mut value = transformed.into_value();
self.response_transform.resequence_stream_value(&mut value);
@@ -324,13 +324,11 @@ impl GrokCanonicalDecoder {
if let Some(item) = value.get("item") {
self.requires_provider_cost |= !token_only_output(item);
}
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
let Ok(transformed) = self
// 转换失败必须终止,不能丢弃工具参数后仍向客户端报告成功。
let transformed = self
.response_transform
.rewrite_stream_event(&event_type, value)
else {
continue;
};
.map_err(|_| protocol_error_marker())?;
for (index, transformed) in transformed.into_iter().enumerate() {
let transformed_type = transformed.event_type().to_owned();
if !client_visible_event(&transformed_type) {
@@ -19,6 +19,9 @@ use crate::{GrokHeader, SecretValue, XaiWireProfileState};
pub const GROK_MODEL_CATALOG_URL: &str = "https://cli-chat-proxy.grok.com/v1/models";
/// 官方 Grok Build credits/billing URL。
pub const GROK_BILLING_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
/// 官方实时订阅查询 URL;额度响应本身不提供套餐事实。
pub const GROK_SUBSCRIPTION_URL: &str =
"https://cli-chat-proxy.grok.com/v1/user?include=subscription";
/// 单次 Grok 模型目录响应允许的最大字节数。
pub const MAX_GROK_MODEL_CATALOG_BYTES: usize = 1024 * 1024;
/// 单次 Grok billing 响应允许的最大字节数。
@@ -28,6 +31,7 @@ pub(crate) const MAX_CATALOG_MODELS: usize = 2_048;
const MAX_DISPLAY_NAME_BYTES: usize = 256;
const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
const MAX_ETAG_BYTES: usize = 256;
const SUBSCRIPTION_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// 构造模型目录 OAuth session 失败。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
@@ -255,7 +259,7 @@ pub trait GrokModelCatalogTransport: Send + Sync {
fn execute(&self, request: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_>;
}
/// 交给单次官方 billing GET transport 的完整请求。
/// 交给单次官方计费或订阅 GET transport 的完整请求。
#[derive(Debug)]
pub struct GrokBillingRequest {
endpoint: Url,
@@ -265,8 +269,14 @@ pub struct GrokBillingRequest {
impl GrokBillingRequest {
fn from_session(session: &GrokModelCatalogSession) -> Result<Self, GrokBillingError> {
let endpoint =
Url::parse(GROK_BILLING_URL).map_err(|_| GrokBillingError::InvalidRequest)?;
Self::for_endpoint(session, GROK_BILLING_URL)
}
fn for_endpoint(
session: &GrokModelCatalogSession,
endpoint: &str,
) -> Result<Self, GrokBillingError> {
let endpoint = Url::parse(endpoint).map_err(|_| GrokBillingError::InvalidRequest)?;
let mut headers = vec![
GrokHeader::sensitive(
"authorization",
@@ -291,7 +301,7 @@ impl GrokBillingRequest {
})
}
/// 返回固定官方 `/v1/billing?format=credits` URL。
/// 返回固定官方计费或订阅 URL。
#[must_use]
pub const fn endpoint(&self) -> &Url {
&self.endpoint
@@ -456,6 +466,54 @@ impl GrokBillingClient {
.map_err(|_| GrokBillingError::Transport)?;
parse_grok_billing(response.body())
}
/// 查询实时订阅,只返回套餐事实,不保存用户资料。
pub async fn fetch_subscription(
&self,
session: &GrokModelCatalogSession,
) -> Result<Option<String>, GrokBillingError> {
let request = GrokBillingRequest::for_endpoint(session, GROK_SUBSCRIPTION_URL)?;
let response = tokio::time::timeout(
SUBSCRIPTION_REQUEST_TIMEOUT,
self.transport.execute(request),
)
.await
.map_err(|_| GrokBillingError::Transport)?
.map_err(|_| GrokBillingError::Transport)?;
parse_subscription_tier(response.body())
}
}
fn parse_subscription_tier(body: &[u8]) -> Result<Option<String>, GrokBillingError> {
if body.len() > MAX_GROK_BILLING_BYTES {
return Err(GrokBillingError::ResponseTooLarge);
}
let document: Map<String, Value> =
serde_json::from_slice(body).map_err(|_| GrokBillingError::InvalidWire)?;
let user_id = document
.get("userId")
.ok_or(GrokBillingError::InvalidWire)?;
validate_dynamic_text(user_id, 1_024)?;
if user_id.as_str().is_none_or(|value| value.trim().is_empty()) {
return Err(GrokBillingError::InvalidWire);
}
match document.get("subscriptionTier") {
Some(value @ Value::String(tier)) => {
validate_dynamic_text(value, 512)?;
let tier = tier.trim();
Ok((!tier.is_empty()).then(|| tier.to_owned()))
}
// 官方仅在存在有效付费订阅时返回套餐名;缺字段或团队身份不能推断为 Free。
Some(Value::Null)
if document.get("principalType").and_then(Value::as_str) == Some("User")
&& document.get("teamId") == Some(&Value::Null)
&& document.get("organizationId") == Some(&Value::Null) =>
{
Ok(Some("Free".to_owned()))
}
None | Some(Value::Null) => Ok(None),
_ => Err(GrokBillingError::InvalidWire),
}
}
impl fmt::Debug for GrokBillingClient {
@@ -15,16 +15,17 @@ pub use canonical::{
GrokCanonicalDecoder, grok_billing_breakdown, grok_billing_breakdown_with_tier,
};
pub use catalog::{
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError,
GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, GrokBillingTransportError,
GrokBillingTransportErrorKind, GrokBillingTransportFuture, GrokBillingTransportResponse,
GrokCatalogApiBackend, GrokCatalogCapabilities, GrokCatalogCapabilityEvidence,
GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel, GrokCatalogReasoningEffort,
GrokModelCatalogClient, GrokModelCatalogError, GrokModelCatalogRequest,
GrokModelCatalogSession, GrokModelCatalogSessionError, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, MAX_GROK_BILLING_BYTES,
MAX_GROK_MODEL_CATALOG_BYTES, parse_grok_billing, parse_grok_model_catalog,
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GROK_SUBSCRIPTION_URL, GrokBillingClient,
GrokBillingError, GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport,
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
GrokBillingTransportResponse, GrokCatalogApiBackend, GrokCatalogCapabilities,
GrokCatalogCapabilityEvidence, GrokCatalogLimits, GrokCatalogMetadata, GrokCatalogModel,
GrokCatalogReasoningEffort, GrokModelCatalogClient, GrokModelCatalogError,
GrokModelCatalogRequest, GrokModelCatalogSession, GrokModelCatalogSessionError,
GrokModelCatalogSnapshot, GrokModelCatalogTransport, GrokModelCatalogTransportError,
GrokModelCatalogTransportErrorKind, GrokModelCatalogTransportFuture,
GrokModelCatalogTransportResponse, MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES,
parse_grok_billing, parse_grok_model_catalog,
};
pub use compaction::{
GrokCompactionDecodeError, GrokCompactionRequest, GrokCompactionSummaryDecoder,
@@ -767,7 +767,6 @@ fn valid_billing_url(url: &Url, host: &str) -> bool {
url.scheme() == "https"
&& url.host_str() == Some(host)
&& url.port_or_known_default() == Some(443)
&& url.path() == OFFICIAL_BILLING_PATH
&& url.username().is_empty()
&& url.password().is_none()
&& url.fragment().is_none()
@@ -775,7 +774,11 @@ fn valid_billing_url(url: &Url, host: &str) -> bool {
&& url
.query_pairs()
.next()
.is_some_and(|(key, value)| key == "format" && value == "credits")
.is_some_and(|(key, value)| match url.path() {
OFFICIAL_BILLING_PATH => key == "format" && value == "credits",
"/v1/user" => key == "include" && value == "subscription",
_ => false,
})
}
#[derive(Debug)]
@@ -40,27 +40,7 @@ pub(super) fn normalize_compaction_input(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let has_structured_compaction_shape = item.contains_key("summary")
|| item.contains_key("id")
|| item.contains_key("status")
|| string_field(item, "type").trim() == "compaction_summary";
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
// xAI reasoning 密文 + 可见 conversation summary。
if !has_structured_compaction_shape {
let Some(summary) = encrypted else {
return Ok(Vec::new());
};
let continuation = format!(
"This session is being continued from a previous conversation that ran out of context. \
The summary below covers the earlier portion of the conversation.\n\n{summary}"
);
return Ok(vec![Value::Object(compaction_summary_message(
&continuation,
))]);
}
// 密文只作为 reasoning 回放;可见摘要只读取 summary。
let mut converted = Vec::with_capacity(2);
if let Some(encrypted) = encrypted {
converted.push(json!({
@@ -339,139 +319,6 @@ pub(super) fn normalize_apply_patch_output_input(
]))
}
pub(super) fn normalize_legacy_local_shell_call_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let call_id = required_trimmed_string(item, "call_id")?;
let action = legacy_shell_action(item.get("action"))?;
let mut converted = Map::from_iter([
("type".to_owned(), Value::String("shell_call".to_owned())),
("call_id".to_owned(), Value::String(call_id.to_owned())),
("action".to_owned(), Value::Object(action)),
]);
for key in ["id", "status", "timeout_ms", "max_output_length"] {
if let Some(value) = item.get(key) {
converted.insert(key.to_owned(), value.clone());
}
}
Ok(converted)
}
pub(super) fn legacy_shell_action(
value: Option<&Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let action = value
.and_then(Value::as_object)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
if !matches!(string_field(action, "type").trim(), "" | "exec") {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let command = legacy_shell_command(action)?;
Ok(Map::from_iter([
("type".to_owned(), Value::String("exec".to_owned())),
(
"commands".to_owned(),
Value::Array(vec![Value::String(command)]),
),
]))
}
pub(super) fn legacy_shell_command(
action: &Map<String, Value>,
) -> Result<String, GrokRequestEncodeError> {
let mut command = match action.get("command") {
Some(Value::String(value)) => value.trim().to_owned(),
Some(Value::Array(parts)) => parts
.iter()
.map(|part| {
part.as_str()
.map(quote_shell_argument)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
})
.collect::<Result<Vec<_>, _>>()?
.join(" "),
_ => action
.get("commands")
.and_then(Value::as_array)
.map(|commands| {
commands
.iter()
.map(|command| {
command
.as_str()
.map(ToOwned::to_owned)
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
})
.collect::<Result<Vec<_>, _>>()
.map(|commands| commands.join("\n"))
})
.transpose()?
.unwrap_or_default(),
};
if command.is_empty() {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
if let Some(environment) = action.get("env").and_then(Value::as_object)
&& !environment.is_empty()
{
let assignments = environment
.iter()
.map(|(name, value)| {
if !valid_environment_name(name) {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let value = value
.as_str()
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
Ok(format!("{name}={}", quote_shell_argument(value)))
})
.collect::<Result<Vec<_>, _>>()?;
command = format!("env {} {command}", assignments.join(" "));
}
if let Some(directory) = action
.get("working_directory")
.and_then(Value::as_str)
.map(str::trim)
.filter(|directory| !directory.is_empty())
{
command = format!("cd {} && {command}", quote_shell_argument(directory));
}
Ok(command)
}
pub(super) fn normalize_legacy_local_shell_output_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
let call_id = required_trimmed_string(item, "call_id")?;
let output = match item.get("output") {
Some(Value::Array(output)) => output.clone(),
Some(Value::String(output)) => {
let exit_code = item
.get("exit_code")
.and_then(Value::as_i64)
.unwrap_or_else(|| {
i64::from(string_field(item, "status").eq_ignore_ascii_case("failed"))
});
vec![shell_output_block(output, "", "exit", Some(exit_code))]
}
_ => return Err(GrokRequestEncodeError::InvalidRequestNormalization),
};
let mut converted = Map::from_iter([
(
"type".to_owned(),
Value::String("shell_call_output".to_owned()),
),
("call_id".to_owned(), Value::String(call_id.to_owned())),
("output".to_owned(), Value::Array(output)),
]);
if let Some(value) = item.get("max_output_length")
&& !value.is_null()
{
converted.insert("max_output_length".to_owned(), value.clone());
}
Ok(converted)
}
pub(super) fn normalize_shell_call_output_input(
item: &Map<String, Value>,
) -> Result<Map<String, Value>, GrokRequestEncodeError> {
@@ -555,24 +402,3 @@ pub(super) fn shell_output_block(
("outcome", Value::Object(outcome)),
])
}
pub(super) fn quote_shell_argument(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_@%+=:,./-".contains(character))
{
return value.to_owned();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(super) fn valid_environment_name(value: &str) -> bool {
value.chars().enumerate().all(|(index, character)| {
character.is_ascii_alphabetic()
|| character == '_'
|| (index > 0 && character.is_ascii_digit())
}) && !value.is_empty()
}
@@ -0,0 +1,332 @@
//! 会话身份、租户隔离与缓存路由。
use super::*;
const IDENTITY_FIELDS: &[&str] = &[
"Authorization",
"authorization",
"Cookie",
"cookie",
"accessToken",
"access_token",
"accountId",
"account_id",
"cookies",
"email",
"idToken",
"id_token",
"refreshToken",
"refresh_token",
"sessionToken",
"session_token",
"teamId",
"team_id",
"token",
"userId",
"user_id",
"x-email",
"x-grok-user-id",
"x-userid",
];
const ACCOUNT_BOUND_FIELDS: &[&str] = &[
"agentId",
"agent_id",
"conversation",
"conversationId",
"conversation_id",
"previousResponseId",
"previous_response_id",
"responseId",
"response_id",
"sessionId",
"session_id",
"x-grok-agent-id",
"x-grok-conv-id",
"x-grok-session-id",
];
const SESSION_FIELDS: &[&str] = &[
"prompt_cache_key",
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
const PROTOCOL_CONTEXT_SESSION_FIELDS: &[&str] = &["conversation_id", "session_id", "thread_id"];
const MAX_SESSION_SEED_BYTES: usize = 1_024;
const GROK_CACHE_ROUTE_TOOLS: &[&str] = &["web_search", "x_search"];
pub(super) fn sanitize_account_identity(body: &mut Map<String, Value>) {
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
body.remove(*field);
}
}
pub(super) fn sanitize_client_metadata(body: &mut Map<String, Value>) {
// Codex 的 client_metadata 是本地 transport envelope,可能包含工作目录、仓库地址、
// installation/session 标识。会话亲和信息已在调用本函数前提取,整个 envelope 都不能
// 越过 Grok Build 边界。
body.remove("client_metadata");
body.remove("metadata");
}
pub(super) fn enable_grok_prompt_cache_route(
body: &mut Map<String, Value>,
upstream_model: &str,
response: &mut GrokResponseTransform,
) {
let tools = body
.entry("tools".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
let Value::Array(tools) = tools else {
return;
};
response.observe_upstream_cache_tools(tools);
if is_cache_media_model(upstream_model) || has_tool_type(tools, "image_generation") {
return;
}
if tools.is_empty() {
for tool in GROK_CACHE_ROUTE_TOOLS {
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
response.mark_injected_cache_tool(tool);
}
// 无客户端工具时以 none 选中缓存路由,同时不授予搜索能力。
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
} else if !has_tool_type(tools, "x_search") {
tools.push(json_object([(
"type",
Value::String("x_search".to_owned()),
)]));
response.mark_injected_cache_tool("x_search");
}
}
fn is_cache_media_model(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
["image", "imagine", "video"]
.into_iter()
.any(|marker| model.contains(marker))
}
pub(super) fn explicit_session_seed(
request: &GenerateRequest,
body: &Map<String, Value>,
) -> Option<String> {
request
.prompt_cache_key()
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| first_session_value(body))
.or_else(|| {
body.get("metadata")
.and_then(Value::as_object)
.and_then(first_session_value)
})
.or_else(|| {
PROTOCOL_CONTEXT_SESSION_FIELDS.iter().find_map(|field| {
request
.protocol_payload()
.context()
.get(*field)
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
})
}
fn first_session_value(body: &Map<String, Value>) -> Option<String> {
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned);
prompt_cache_key
.or_else(|| {
let metadata = body.get("metadata")?.as_object()?;
["session_id", "sessionId"]
.into_iter()
.find_map(|field| metadata.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| {
metadata
.get("user_id")
.and_then(Value::as_str)
.and_then(session_seed_from_user_id)
})
})
.or_else(|| {
SESSION_FIELDS[1..]
.iter()
.find_map(|field| body.get(*field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
}
fn valid_session_seed(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= MAX_SESSION_SEED_BYTES
&& !value.chars().any(char::is_control))
.then_some(value)
}
fn session_seed_from_user_id(value: &str) -> Option<String> {
let value = value.trim();
if let Ok(Value::Object(embedded)) = serde_json::from_str::<Value>(value)
&& let Some(seed) = ["session_id", "sessionId"]
.into_iter()
.find_map(|field| embedded.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
{
return Some(seed.to_owned());
}
value
.rfind("_session_")
.and_then(|index| valid_session_seed(&value[index + "_session_".len()..]))
.map(ToOwned::to_owned)
}
pub(super) fn resolve_session_identity(
client_api_key_ref: &str,
upstream_model: &str,
explicit_seed: Option<&str>,
body: &Map<String, Value>,
) -> Option<(String, GrokSessionAffinityKey)> {
let model = upstream_model.trim().to_ascii_lowercase();
if client_api_key_ref.is_empty() || model.is_empty() {
return None;
}
if let Some(seed) = explicit_seed {
let upstream_source =
format!("xai:build-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{seed}");
let affinity_source = format!(
"xai:build-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{seed}"
);
return Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
));
}
let (system, first_user) = message_anchors(body);
let first_user = truncate_anchor(&first_user, 200);
if first_user.is_empty() {
return None;
}
let system = truncate_anchor(&system, 100);
let upstream_source = format!(
"xai:build-soft-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{system}:{first_user}"
);
let affinity_source = format!(
"xai:build-soft-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{system}:{first_user}"
);
Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
))
}
fn digest_uuid(source: &str) -> String {
let digest = Sha256::digest(source);
let value = digest[..16]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!(
"{}-{}-{}-{}-{}",
&value[0..8],
&value[8..12],
&value[12..16],
&value[16..20],
&value[20..32]
)
}
fn truncate_anchor(value: &str, max_chars: usize) -> String {
value.trim().chars().take(max_chars).collect()
}
fn message_anchors(body: &Map<String, Value>) -> (String, String) {
let mut system = body
.get("instructions")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| body.get("system").map(flatten_message_content))
.unwrap_or_default();
let mut first_user = String::new();
if let Some(messages) = body.get("messages").and_then(Value::as_array) {
collect_role_anchors(messages, &mut system, &mut first_user);
}
if first_user.is_empty() {
match body.get("input") {
Some(Value::String(value)) => first_user = value.trim().to_owned(),
Some(Value::Array(items)) => {
collect_role_anchors(items, &mut system, &mut first_user);
}
_ => {}
}
}
(system, first_user)
}
fn collect_role_anchors(items: &[Value], system: &mut String, first_user: &mut String) {
for item in items {
let Some(item) = item.as_object() else {
continue;
};
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if !item_type.is_empty() && item_type != "message" {
continue;
}
let role = item
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let content = item
.get("content")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| {
item.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_default();
if content.is_empty() {
continue;
}
match role.as_str() {
"system" | "developer" if system.is_empty() => *system = content,
"user" if first_user.is_empty() => *first_user = content,
"" if first_user.is_empty() => *first_user = content,
_ => {}
}
if !first_user.is_empty() && !system.is_empty() {
break;
}
}
}
fn flatten_message_content(value: &Value) -> String {
match value {
Value::String(value) => value.trim().to_owned(),
Value::Array(parts) => parts
.iter()
.filter_map(Value::as_object)
.filter(|part| {
matches!(
part.get("type").and_then(Value::as_str).unwrap_or_default(),
"" | "text" | "input_text" | "output_text"
)
})
.filter_map(|part| part.get("text").and_then(Value::as_str).map(str::trim))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
@@ -11,67 +11,21 @@ use sha2::{Digest as _, Sha256};
use super::{GrokSessionAffinityKey, XAI_PROVIDER_NAME};
mod history;
mod identity;
mod model;
mod response;
mod schema;
mod tools;
pub(super) use history::strip_invalid_encrypted_reasoning_from_body;
pub(crate) use response::GrokResponseTransform;
use history::*;
use identity::*;
use model::*;
use schema::*;
use tools::*;
const IDENTITY_FIELDS: &[&str] = &[
"Authorization",
"authorization",
"Cookie",
"cookie",
"accessToken",
"access_token",
"accountId",
"account_id",
"cookies",
"email",
"idToken",
"id_token",
"refreshToken",
"refresh_token",
"sessionToken",
"session_token",
"teamId",
"team_id",
"token",
"userId",
"user_id",
"x-email",
"x-grok-user-id",
"x-userid",
];
const ACCOUNT_BOUND_FIELDS: &[&str] = &[
"agentId",
"agent_id",
"conversation",
"conversationId",
"conversation_id",
"previousResponseId",
"previous_response_id",
"responseId",
"response_id",
"sessionId",
"session_id",
"x-grok-agent-id",
"x-grok-conv-id",
"x-grok-session-id",
];
const SESSION_FIELDS: &[&str] = &[
"prompt_cache_key",
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
const PROTOCOL_CONTEXT_SESSION_FIELDS: &[&str] = &["conversation_id", "session_id", "thread_id"];
const MAX_SESSION_SEED_BYTES: usize = 1_024;
const GROK_CACHE_ROUTE_TOOLS: &[&str] = &["web_search", "x_search"];
/// Grok CLI 在历史条目上注入、Grok Build 无法反序列化的内部键;只在已知
/// 注入位置剥离,避免误伤工具 schema 或输出中恰好同名的键。
const GROK_INTERNAL_HISTORY_KEYS: &[&str] =
@@ -173,6 +127,16 @@ impl GrokResponsesRequest {
strip_invalid_encrypted_reasoning_from_body(&mut self.body)
}
pub(crate) fn instructions(&self) -> Option<&Value> {
self.body
.get("instructions")
.filter(|value| !value.is_null())
}
pub(crate) fn clear_instructions(&mut self) {
self.body.remove("instructions");
}
pub(crate) fn set_previous_response_id(&mut self, response_id: Option<String>) {
match response_id {
Some(response_id) => {
@@ -378,518 +342,18 @@ impl GrokRequestEncodeError {
}
}
fn sanitize_account_identity(body: &mut Map<String, Value>) {
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
body.remove(*field);
}
let Some(Value::Object(metadata)) = body.get_mut("metadata") else {
return;
};
for field in IDENTITY_FIELDS.iter().chain(ACCOUNT_BOUND_FIELDS) {
metadata.remove(*field);
}
}
fn sanitize_client_metadata(body: &mut Map<String, Value>) {
// Codex 的 client_metadata 是本地 transport envelope,可能包含工作目录、仓库地址、
// installation/session 标识。会话亲和信息已在调用本函数前提取,整个 envelope 都不能
// 越过 Grok Build 边界。
body.remove("client_metadata");
}
fn enable_grok_prompt_cache_route(
body: &mut Map<String, Value>,
upstream_model: &str,
response: &mut GrokResponseTransform,
) {
let tools = body
.entry("tools".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
let Value::Array(tools) = tools else {
return;
};
response.observe_upstream_cache_tools(tools);
if is_cache_media_model(upstream_model) || has_tool_type(tools, "image_generation") {
return;
}
if tools.is_empty() {
for tool in GROK_CACHE_ROUTE_TOOLS {
tools.push(json_object([("type", Value::String((*tool).to_owned()))]));
response.mark_injected_cache_tool(tool);
}
// 无客户端工具时以 none 选中缓存路由,同时不授予搜索能力。
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
} else if !has_tool_type(tools, "x_search") {
tools.push(json_object([(
"type",
Value::String("x_search".to_owned()),
)]));
response.mark_injected_cache_tool("x_search");
}
}
fn is_cache_media_model(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
["image", "imagine", "video"]
.into_iter()
.any(|marker| model.contains(marker))
}
fn explicit_session_seed(request: &GenerateRequest, body: &Map<String, Value>) -> Option<String> {
request
.prompt_cache_key()
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| first_session_value(body))
.or_else(|| {
body.get("metadata")
.and_then(Value::as_object)
.and_then(first_session_value)
})
.or_else(|| {
PROTOCOL_CONTEXT_SESSION_FIELDS.iter().find_map(|field| {
request
.protocol_payload()
.context()
.get(*field)
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
})
}
fn first_session_value(body: &Map<String, Value>) -> Option<String> {
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(Value::as_str)
.and_then(valid_session_seed)
.map(ToOwned::to_owned);
prompt_cache_key
.or_else(|| {
let metadata = body.get("metadata")?.as_object()?;
["session_id", "sessionId"]
.into_iter()
.find_map(|field| metadata.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
.or_else(|| {
metadata
.get("user_id")
.and_then(Value::as_str)
.and_then(session_seed_from_user_id)
})
})
.or_else(|| {
SESSION_FIELDS[1..]
.iter()
.find_map(|field| body.get(*field).and_then(Value::as_str))
.and_then(valid_session_seed)
.map(ToOwned::to_owned)
})
}
fn valid_session_seed(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= MAX_SESSION_SEED_BYTES
&& !value.chars().any(char::is_control))
.then_some(value)
}
fn session_seed_from_user_id(value: &str) -> Option<String> {
let value = value.trim();
if let Ok(Value::Object(embedded)) = serde_json::from_str::<Value>(value)
&& let Some(seed) = ["session_id", "sessionId"]
.into_iter()
.find_map(|field| embedded.get(field).and_then(Value::as_str))
.and_then(valid_session_seed)
{
return Some(seed.to_owned());
}
value
.rfind("_session_")
.and_then(|index| valid_session_seed(&value[index + "_session_".len()..]))
.map(ToOwned::to_owned)
}
fn resolve_session_identity(
client_api_key_ref: &str,
upstream_model: &str,
explicit_seed: Option<&str>,
body: &Map<String, Value>,
) -> Option<(String, GrokSessionAffinityKey)> {
let model = upstream_model.trim().to_ascii_lowercase();
if client_api_key_ref.is_empty() || model.is_empty() {
return None;
}
if let Some(seed) = explicit_seed {
let upstream_source =
format!("xai:build-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{seed}");
let affinity_source = format!(
"xai:build-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{seed}"
);
return Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
));
}
let (system, first_user) = message_anchors(body);
let first_user = truncate_anchor(&first_user, 200);
if first_user.is_empty() {
return None;
}
let system = truncate_anchor(&system, 100);
let upstream_source = format!(
"xai:build-soft-session:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{system}:{first_user}"
);
let affinity_source = format!(
"xai:build-soft-affinity:v2:{client_api_key_ref}:{XAI_PROVIDER_NAME}:{model}:{system}:{first_user}"
);
Some((
digest_uuid(&upstream_source),
GrokSessionAffinityKey::from_digest(Sha256::digest(affinity_source).into()),
))
}
fn digest_uuid(source: &str) -> String {
let digest = Sha256::digest(source);
let value = digest[..16]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!(
"{}-{}-{}-{}-{}",
&value[0..8],
&value[8..12],
&value[12..16],
&value[16..20],
&value[20..32]
)
}
fn truncate_anchor(value: &str, max_chars: usize) -> String {
value.trim().chars().take(max_chars).collect()
}
fn message_anchors(body: &Map<String, Value>) -> (String, String) {
let mut system = body
.get("instructions")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| body.get("system").map(flatten_message_content))
.unwrap_or_default();
let mut first_user = String::new();
if let Some(messages) = body.get("messages").and_then(Value::as_array) {
collect_role_anchors(messages, &mut system, &mut first_user);
}
if first_user.is_empty() {
match body.get("input") {
Some(Value::String(value)) => first_user = value.trim().to_owned(),
Some(Value::Array(items)) => {
collect_role_anchors(items, &mut system, &mut first_user);
}
_ => {}
}
}
(system, first_user)
}
fn collect_role_anchors(items: &[Value], system: &mut String, first_user: &mut String) {
for item in items {
let Some(item) = item.as_object() else {
continue;
};
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if !item_type.is_empty() && item_type != "message" {
continue;
}
let role = item
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let content = item
.get("content")
.map(flatten_message_content)
.filter(|value| !value.is_empty())
.or_else(|| {
item.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_default();
if content.is_empty() {
continue;
}
match role.as_str() {
"system" | "developer" if system.is_empty() => *system = content,
"user" if first_user.is_empty() => *first_user = content,
"" if first_user.is_empty() => *first_user = content,
_ => {}
}
if !first_user.is_empty() && !system.is_empty() {
break;
}
}
}
fn flatten_message_content(value: &Value) -> String {
match value {
Value::String(value) => value.trim().to_owned(),
Value::Array(parts) => parts
.iter()
.filter_map(Value::as_object)
.filter(|part| {
matches!(
part.get("type").and_then(Value::as_str).unwrap_or_default(),
"" | "text" | "input_text" | "output_text"
)
})
.filter_map(|part| part.get("text").and_then(Value::as_str).map(str::trim))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn normalize_responses_request(
body: &mut Map<String, Value>,
) -> Result<GrokResponseTransform, GrokRequestEncodeError> {
if let Some(response_format) = body.remove("response_format") {
let text = body
.entry("text".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if text.is_null() {
*text = Value::Object(Map::new());
}
let text = text
.as_object_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "text" })?;
if text.get("format").is_none_or(Value::is_null) {
text.insert(
"format".to_owned(),
normalize_response_format(response_format)
.map_err(|error| error.at_field("response_format"))?,
);
for field in ["response_format", "reasoning_effort", "reasoningEffort"] {
if body.contains_key(field) {
return Err(GrokRequestEncodeError::InvalidRequestField { field });
}
}
patch_reasoning_text_types(body);
ToolNormalizer::new().normalize(body)
}
fn normalize_build_request(
body: &mut Map<String, Value>,
upstream_model: &str,
) -> Result<(), GrokRequestEncodeError> {
apply_build_response_defaults(body)?;
normalize_build_reasoning_effort(body, upstream_model);
sanitize_build_model_fields(body, upstream_model);
Ok(())
}
fn apply_build_response_defaults(
body: &mut Map<String, Value>,
) -> Result<(), GrokRequestEncodeError> {
if body.get("store").is_none_or(Value::is_null) {
body.insert("store".to_owned(), Value::Bool(false));
}
let include = body
.entry("include".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
if include.is_null() {
*include = Value::Array(Vec::new());
}
let include = include
.as_array_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "include" })?;
if include.iter().any(|value| !value.is_string()) {
return Err(GrokRequestEncodeError::InvalidRequestField { field: "include" });
}
if !include
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content"))
{
include.push(Value::String("reasoning.encrypted_content".to_owned()));
}
Ok(())
}
fn normalize_build_reasoning_effort(body: &mut Map<String, Value>, upstream_model: &str) {
let supports_effort = grok_model_supports_reasoning_effort(upstream_model);
if let Some(reasoning) = body.get_mut("reasoning").and_then(Value::as_object_mut) {
if let Some(effort) = reasoning.remove("effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort)
{
reasoning.insert("effort".to_owned(), Value::String(effort.to_owned()));
}
if reasoning.is_empty() {
body.remove("reasoning");
}
}
if let Some(effort) = body.remove("reasoning_effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort)
{
body.insert(
"reasoning_effort".to_owned(),
Value::String(effort.to_owned()),
);
}
let camel_effort = body.remove("reasoningEffort");
if !body.contains_key("reasoning_effort")
&& supports_effort
&& let Some(effort) = camel_effort
.as_ref()
.and_then(normalize_grok_reasoning_effort_value)
{
body.insert(
"reasoning_effort".to_owned(),
Value::String(effort.to_owned()),
);
}
if is_grok_composer_model(upstream_model) {
body.remove("reasoning");
body.remove("reasoning_effort");
body.remove("reasoningEffort");
}
}
fn normalize_grok_reasoning_effort_value(value: &Value) -> Option<&'static str> {
let normalized = value
.as_str()?
.trim()
.to_ascii_lowercase()
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' '))
.collect::<String>();
match normalized.as_str() {
"none" => Some("none"),
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"high" | "xhigh" | "extrahigh" | "max" | "ultra" => Some("high"),
_ => None,
}
}
fn strip_grok_provider_prefix(model: &str) -> &str {
let model = model.trim();
let lower = model.to_ascii_lowercase();
["xai/", "x-ai/", "grok/"]
.into_iter()
.find_map(|prefix| {
lower
.starts_with(prefix)
.then(|| model[prefix.len()..].trim())
})
.unwrap_or(model)
}
fn grok_model_last_slug(model: &str) -> String {
model
.trim()
.rsplit_once('/')
.map_or_else(|| model.trim(), |(_, slug)| slug.trim())
.to_ascii_lowercase()
}
fn is_grok_composer_model(model: &str) -> bool {
matches!(
grok_model_last_slug(model).as_str(),
"grok-composer" | "grok-composer-2.5-fast" | "composer-2.5"
)
}
fn grok_model_supports_reasoning_effort(model: &str) -> bool {
matches!(
strip_grok_provider_prefix(model)
.to_ascii_lowercase()
.as_str(),
"grok-4.5"
| "grok-4.5-latest"
| "grok-4.6"
| "grok-4.6-latest"
| "grok-4.3"
| "grok-4.3-latest"
| "grok-3-mini"
| "grok-3-mini-fast"
| "grok-4.20-0309-reasoning"
| "grok-4.20-reasoning"
| "grok-4.20-multi-agent-0309"
)
}
fn sanitize_build_model_fields(body: &mut Map<String, Value>, upstream_model: &str) {
for field in ["prompt_cache_retention", "safety_identifier"] {
body.remove(field);
}
if upstream_model.trim().eq_ignore_ascii_case("grok-4.5") {
for field in [
"presence_penalty",
"presencePenalty",
"frequency_penalty",
"frequencyPenalty",
"stop",
] {
body.remove(field);
}
}
if grok_model_last_slug(upstream_model).starts_with("grok-4.20") {
body.remove("logprobs");
body.remove("top_logprobs");
}
delete_json_field_recursive(body, "external_web_access");
}
fn delete_json_field_recursive(object: &mut Map<String, Value>, field: &str) {
object.remove(field);
for value in object.values_mut() {
delete_json_field_from_value(value, field);
}
}
fn delete_json_field_from_value(value: &mut Value, field: &str) {
match value {
Value::Object(child) => delete_json_field_recursive(child, field),
Value::Array(children) => {
for child in children {
delete_json_field_from_value(child, field);
}
}
_ => {}
}
}
fn normalize_response_format(value: Value) -> Result<Value, GrokRequestEncodeError> {
let Some(format) = value.as_object() else {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
};
if format.get("type").and_then(Value::as_str) != Some("json_schema") {
return Ok(value);
}
let Some(schema) = format.get("json_schema").and_then(Value::as_object) else {
return Ok(value);
};
let mut normalized = Map::new();
normalized.insert("type".to_owned(), Value::String("json_schema".to_owned()));
normalized.extend(
schema
.iter()
.filter(|(key, _)| key.as_str() != "type")
.map(|(key, value)| (key.clone(), value.clone())),
);
Ok(Value::Object(normalized))
}
fn patch_reasoning_text_types(body: &mut Map<String, Value>) {
let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else {
return;
@@ -0,0 +1,144 @@
//! 当前 Responses 参数到 Grok 模型能力的转换。
use super::*;
pub(super) fn normalize_build_request(
body: &mut Map<String, Value>,
upstream_model: &str,
) -> Result<(), GrokRequestEncodeError> {
apply_build_response_defaults(body)?;
normalize_build_reasoning_effort(body, upstream_model);
sanitize_build_model_fields(body, upstream_model);
Ok(())
}
fn apply_build_response_defaults(
body: &mut Map<String, Value>,
) -> Result<(), GrokRequestEncodeError> {
if body.get("store").is_none_or(Value::is_null) {
body.insert("store".to_owned(), Value::Bool(false));
}
let include = body
.entry("include".to_owned())
.or_insert_with(|| Value::Array(Vec::new()));
if include.is_null() {
*include = Value::Array(Vec::new());
}
let include = include
.as_array_mut()
.ok_or(GrokRequestEncodeError::InvalidRequestField { field: "include" })?;
if include.iter().any(|value| !value.is_string()) {
return Err(GrokRequestEncodeError::InvalidRequestField { field: "include" });
}
if !include
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content"))
{
include.push(Value::String("reasoning.encrypted_content".to_owned()));
}
Ok(())
}
fn normalize_build_reasoning_effort(body: &mut Map<String, Value>, upstream_model: &str) {
let supports_effort = grok_model_supports_reasoning_effort(upstream_model);
if let Some(reasoning) = body.get_mut("reasoning").and_then(Value::as_object_mut) {
if let Some(effort) = reasoning.remove("effort")
&& supports_effort
&& let Some(effort) = normalize_grok_reasoning_effort_value(&effort, upstream_model)
{
reasoning.insert("effort".to_owned(), Value::String(effort.to_owned()));
}
if reasoning.is_empty() {
body.remove("reasoning");
}
}
if is_grok_composer_model(upstream_model) {
body.remove("reasoning");
}
}
fn normalize_grok_reasoning_effort_value(value: &Value, model: &str) -> Option<&'static str> {
let normalized = value.as_str()?.trim().to_ascii_lowercase();
match normalized.as_str() {
"none" => Some("none"),
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"xhigh" if matches!(model, "grok-4.6" | "grok-4.6-latest") => Some("xhigh"),
"high" | "xhigh" => Some("high"),
_ => None,
}
}
pub(super) fn strip_grok_provider_prefix(model: &str) -> &str {
let model = model.trim();
let lower = model.to_ascii_lowercase();
["xai/", "x-ai/", "grok/"]
.into_iter()
.find_map(|prefix| {
lower
.starts_with(prefix)
.then(|| model[prefix.len()..].trim())
})
.unwrap_or(model)
}
pub(super) fn grok_model_last_slug(model: &str) -> String {
model
.trim()
.rsplit_once('/')
.map_or_else(|| model.trim(), |(_, slug)| slug.trim())
.to_ascii_lowercase()
}
pub(super) fn is_grok_composer_model(model: &str) -> bool {
matches!(
grok_model_last_slug(model).as_str(),
"grok-composer" | "grok-composer-2.5-fast" | "composer-2.5"
)
}
fn grok_model_supports_reasoning_effort(model: &str) -> bool {
matches!(
strip_grok_provider_prefix(model)
.to_ascii_lowercase()
.as_str(),
"grok-4.5"
| "grok-4.5-latest"
| "grok-4.6"
| "grok-4.6-latest"
| "grok-4.3"
| "grok-4.3-latest"
| "grok-3-mini"
| "grok-3-mini-fast"
| "grok-4.20-0309-reasoning"
| "grok-4.20-reasoning"
| "grok-4.20-multi-agent-0309"
)
}
fn sanitize_build_model_fields(body: &mut Map<String, Value>, upstream_model: &str) {
for field in ["prompt_cache_retention", "safety_identifier"] {
body.remove(field);
}
if upstream_model.trim().eq_ignore_ascii_case("grok-4.5") {
for field in [
"presence_penalty",
"presencePenalty",
"frequency_penalty",
"frequencyPenalty",
"stop",
] {
body.remove(field);
}
}
if ["grok-4.20", "grok-4.3", "grok-4.5", "grok-4.6"]
.iter()
.any(|prefix| grok_model_last_slug(upstream_model).starts_with(prefix))
{
body.remove("logprobs");
body.remove("top_logprobs");
}
body.remove("external_web_access");
}
@@ -29,14 +29,6 @@ impl ToolIdentity {
pub(super) fn is_root_custom_apply_patch(&self) -> bool {
self.kind == ToolKind::Custom && self.namespace.is_empty() && self.name == "apply_patch"
}
pub(super) fn custom_argument_field(&self) -> &'static str {
if self.is_root_custom_apply_patch() {
"patch"
} else {
"input"
}
}
}
#[derive(Debug, Clone)]
@@ -54,7 +46,6 @@ pub(crate) struct GrokResponseTransform {
pub(super) aliases: BTreeMap<String, ToolIdentity>,
pub(super) function_schemas: BTreeMap<String, Value>,
pub(super) visible_tools: Vec<Value>,
pub(super) legacy_local_shell: bool,
pub(super) filter_x_search: bool,
pub(super) injected_tool_types: BTreeSet<String>,
pub(super) client_declared_tools: BTreeSet<String>,
@@ -89,7 +80,6 @@ impl GrokResponseTransform {
self.aliases.is_empty()
&& self.function_schemas.is_empty()
&& self.visible_tools.is_empty()
&& !self.legacy_local_shell
&& !self.filter_x_search
&& self.injected_tool_types.is_empty()
}
@@ -194,7 +184,16 @@ impl GrokResponseTransform {
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)?;
match state.identity.kind {
ToolKind::Custom => {
if state.arguments.len().saturating_add(delta.len())
> MAX_BUFFERED_FUNCTION_ARGUMENTS_BYTES
|| self.stream_argument_bytes.saturating_add(delta.len())
> MAX_TOTAL_BUFFERED_FUNCTION_ARGUMENTS_BYTES
{
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
state.arguments.push_str(delta);
self.stream_argument_bytes =
self.stream_argument_bytes.saturating_add(delta.len());
state.last_delta = value.as_object().cloned().map(|mut payload| {
payload.remove("delta");
payload
@@ -262,7 +261,7 @@ impl GrokResponseTransform {
.and_then(Value::as_str)
.filter(|arguments| !arguments.is_empty())
.unwrap_or(&state.arguments);
let input = decode_custom_tool_input(&state.identity, arguments);
let input = decode_custom_tool_input(arguments);
let mut output = Vec::with_capacity(2);
if let Some(delta) = state.last_delta.as_ref() {
output.push(GrokTransformedWireEvent {
@@ -287,6 +286,9 @@ impl GrokResponseTransform {
),
});
if let Some(state) = self.stream_calls.get_mut(&primary) {
self.stream_argument_bytes = self
.stream_argument_bytes
.saturating_sub(state.arguments.len());
state.arguments.clear();
state.last_delta = None;
}
@@ -452,25 +454,20 @@ impl GrokResponseTransform {
}
fn rewrite_response_value(&self, value: &mut Value) -> Result<(), GrokRequestEncodeError> {
match value {
Value::Array(values) => {
for value in values {
self.rewrite_response_value(value)?;
}
}
Value::Object(object) => {
for value in object.values_mut() {
self.rewrite_response_value(value)?;
}
match string_field(object, "type") {
"function_call" => self.rewrite_function_call(object)?,
"shell_call" if self.legacy_local_shell => {
rewrite_legacy_local_shell_call(object)
// 只转换协议中的输出条目,避免把 metadata、schema 或工具业务结果当成调用。
if let Some(item) = value.get_mut("item").and_then(Value::as_object_mut)
&& string_field(item, "type") == "function_call"
{
self.rewrite_function_call(item)?;
}
for pointer in ["/output", "/response/output"] {
if let Some(items) = value.pointer_mut(pointer).and_then(Value::as_array_mut) {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
if string_field(item, "type") == "function_call" {
self.rewrite_function_call(item)?;
}
_ => {}
}
}
_ => {}
}
Ok(())
}
@@ -515,7 +512,7 @@ impl GrokResponseTransform {
Value::String(identity.namespace.clone()),
);
}
let input = decode_custom_tool_input(identity, string_field(call, "arguments"));
let input = decode_custom_tool_input(string_field(call, "arguments"));
call.insert("input".to_owned(), Value::String(input));
call.remove("arguments");
}
@@ -542,6 +539,8 @@ impl GrokResponseTransform {
call.remove("arguments");
}
}
let kind = string_field(call, "type").to_owned();
retype_tool_item_id(call, "id", &kind);
Ok(())
}
@@ -759,15 +758,38 @@ pub(super) fn custom_tool_stream_payload(
result.insert(key.to_owned(), value.clone());
}
}
retype_tool_item_id(&mut result, "item_id", "custom_tool_call");
Value::Object(result)
}
pub(super) fn decode_custom_tool_input(identity: &ToolIdentity, arguments: &str) -> String {
/// item ID 随工具类型转换,call_id 始终保留原始配对语义。
pub(super) fn retype_tool_item_id(item: &mut Map<String, Value>, field: &str, kind: &str) {
let prefix = match kind {
"function_call" => "fc_",
"custom_tool_call" => "ctc_",
"tool_search_call" => "tsc_",
_ => return,
};
let Some(id) = item.get(field).and_then(Value::as_str) else {
return;
};
if id.starts_with(prefix) {
return;
}
if let Some(suffix) = ["fc_", "ctc_", "tsc_"]
.into_iter()
.find_map(|known| id.strip_prefix(known))
{
item.insert(field.to_owned(), Value::String(format!("{prefix}{suffix}")));
}
}
pub(super) fn decode_custom_tool_input(arguments: &str) -> String {
serde_json::from_str::<Value>(arguments)
.ok()
.and_then(|value| {
value
.get(identity.custom_argument_field())
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
@@ -796,34 +818,6 @@ pub(super) fn decode_apply_patch_arguments(
validate_apply_patch_operation(wrapper.get("operation"))
}
pub(super) fn rewrite_legacy_local_shell_call(call: &mut Map<String, Value>) {
let commands = call
.get("action")
.and_then(Value::as_object)
.and_then(|action| action.get("commands"))
.and_then(Value::as_array)
.map(|commands| {
commands
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
call.insert(
"type".to_owned(),
Value::String("local_shell_call".to_owned()),
);
call.insert(
"action".to_owned(),
json_object([
("type", Value::String("exec".to_owned())),
("command", Value::String(commands)),
]),
);
call.remove("max_output_length");
}
impl fmt::Debug for GrokResponseTransform {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -831,7 +825,6 @@ impl fmt::Debug for GrokResponseTransform {
.field("alias_count", &self.aliases.len())
.field("function_schema_count", &self.function_schemas.len())
.field("visible_tool_count", &self.visible_tools.len())
.field("legacy_local_shell", &self.legacy_local_shell)
.field("filter_x_search", &self.filter_x_search)
.field("injected_tool_types", &self.injected_tool_types)
.finish()
@@ -0,0 +1,395 @@
//! 工具 JSON Schema 校验与参数数值归一化。
use super::*;
/// 仅移除参数根节点的 null 分支,保持嵌套 schema 的业务语义。
pub(super) fn normalize_function_parameters_root(
value: &Value,
) -> Result<Option<Value>, GrokRequestEncodeError> {
let Some(schema) = value.as_object() else {
return Ok(None);
};
let mut normalized = schema.clone();
let mut changed = false;
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
if removed_null {
let remaining = types
.into_iter()
.filter(|value| value.as_str() != Some("null"))
.collect::<Vec<_>>();
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
return Err(invalid_function_parameters_root());
}
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
changed = true;
}
}
for keyword in ["anyOf", "oneOf"] {
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
continue;
};
let removed_null = branches.iter().any(is_null_only_schema);
if !removed_null {
continue;
}
let remaining = branches
.into_iter()
.filter(|branch| !is_null_only_schema(branch))
.collect::<Vec<_>>();
if remaining.is_empty()
|| remaining.iter().any(|branch| {
branch.as_object().is_none_or(|branch| {
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
})
})
{
return Err(invalid_function_parameters_root());
}
if remaining.len() == 1 && normalized.len() == 1 {
normalized = remaining[0]
.as_object()
.cloned()
.ok_or_else(invalid_function_parameters_root)?;
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
} else {
normalized.insert(keyword.to_owned(), Value::Array(remaining));
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
}
changed = true;
}
Ok(changed.then_some(Value::Object(normalized)))
}
fn is_null_only_schema(value: &Value) -> bool {
let Some(schema) = value.as_object() else {
return false;
};
match schema.get("type") {
Some(Value::String(kind)) => kind == "null",
Some(Value::Array(types)) if !types.is_empty() => {
types.iter().all(|kind| kind.as_str() == Some("null"))
}
_ => false,
}
}
fn is_object_root_schema(
schema: &Map<String, Value>,
root: &Map<String, Value>,
visited: &mut BTreeSet<String>,
) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => return kind == "object",
Some(Value::Array(types)) if !types.is_empty() => {
return types.iter().all(|kind| kind.as_str() == Some("object"));
}
Some(_) => return false,
None => {}
}
if schema.contains_key("properties") {
return true;
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return false;
};
if !visited.insert(reference.to_owned()) {
return false;
}
resolve_local_schema_ref(root, reference)
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
}
fn resolve_local_schema_ref<'a>(
root: &'a Map<String, Value>,
reference: &str,
) -> Option<&'a Map<String, Value>> {
if reference == "#" {
return Some(root);
}
let path = reference.strip_prefix("#/")?;
let mut segments = path.split('/');
let first = decode_json_pointer_segment(segments.next()?);
let mut current = root.get(&first)?;
for segment in segments {
let segment = decode_json_pointer_segment(segment);
current = current.as_object()?.get(&segment)?;
}
current.as_object()
}
fn decode_json_pointer_segment(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
pub(super) const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters",
}
}
pub(super) fn encode_function_arguments(
value: Option<&Value>,
) -> Result<String, GrokRequestEncodeError> {
match value {
Some(Value::String(value)) => Ok(value.clone()),
None | Some(Value::Null) => Ok("{}".to_owned()),
Some(value) => serde_json::to_string(value)
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization),
}
}
pub(super) fn normalize_function_arguments(arguments: &str, schema: &Value) -> Option<String> {
if arguments.trim().is_empty() || !schema.is_object() {
return None;
}
let mut value = serde_json::from_str::<Value>(arguments).ok()?;
if !normalize_argument_value(&mut value, schema, schema, 0) {
return None;
}
serde_json::to_string(&value).ok()
}
fn normalize_argument_value(value: &mut Value, schema: &Value, root: &Value, depth: usize) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
let mut changed = false;
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
{
changed |= normalize_argument_value(value, resolved, root, depth + 1);
}
for keyword in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = schema.get(keyword).and_then(Value::as_array) {
for branch in branches {
changed |= normalize_argument_value(value, branch, root, depth + 1);
}
}
}
if schema_requires_integer(schema)
&& let Value::Number(number) = value
&& let Some(normalized) = normalize_integral_number(number)
{
*number = normalized;
return true;
}
match value {
Value::Object(object) => {
let properties = schema.get("properties").and_then(Value::as_object);
let additional = schema
.get("additionalProperties")
.filter(|value| value.is_object());
for (key, item) in object {
let property = properties
.and_then(|properties| properties.get(key))
.or(additional);
if let Some(property) = property {
changed |= normalize_argument_value(item, property, root, depth + 1);
}
}
}
Value::Array(items) => {
let prefix = schema.get("prefixItems").and_then(Value::as_array);
let item_schema = schema.get("items").filter(|value| value.is_object());
for (index, item) in items.iter_mut().enumerate() {
let schema = prefix
.and_then(|prefix| prefix.get(index))
.filter(|value| value.is_object())
.or(item_schema);
if let Some(schema) = schema {
changed |= normalize_argument_value(item, schema, root, depth + 1);
}
}
}
_ => {}
}
changed
}
fn schema_requires_integer(schema: &Map<String, Value>) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => kind == "integer",
Some(Value::Array(kinds)) => {
let mut integer = false;
for kind in kinds.iter().filter_map(Value::as_str) {
if kind == "number" {
return false;
}
integer |= kind == "integer";
}
integer
}
_ => false,
}
}
fn normalize_integral_number(number: &serde_json::Number) -> Option<serde_json::Number> {
let raw = number.to_string();
if raw.len() > MAX_JSON_NUMBER_TEXT_BYTES || !raw.contains(['.', 'e', 'E']) {
return None;
}
let (mantissa, exponent_text) = raw.find(['e', 'E']).map_or((raw.as_str(), ""), |index| {
(&raw[..index], &raw[index + 1..])
});
let (negative, mantissa) = mantissa
.strip_prefix('-')
.map_or((false, mantissa), |value| (true, value));
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let mut digits = format!("{whole}{fraction}")
.trim_start_matches('0')
.to_owned();
if digits.is_empty() {
return "0".parse().ok().filter(|_| raw != "0");
}
let exponent = parse_bounded_decimal_exponent(exponent_text)?;
let decimal_shift = exponent - i64::try_from(fraction.len()).ok()?;
if decimal_shift < 0 {
let fractional_digits = usize::try_from(-decimal_shift).ok()?;
if fractional_digits > digits.len()
|| !digits[digits.len() - fractional_digits..]
.bytes()
.all(|byte| byte == b'0')
{
return None;
}
digits.truncate(digits.len() - fractional_digits);
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() {
return "0".parse().ok();
}
digits = trimmed.to_owned();
} else if decimal_shift > 0 {
let shift = usize::try_from(decimal_shift).ok()?;
if shift
> MAX_EXACT_JSON_INTEGER_TEXT
.len()
.saturating_sub(digits.len())
{
return None;
}
digits.extend(std::iter::repeat_n('0', shift));
}
if digits.len() > MAX_EXACT_JSON_INTEGER_TEXT.len()
|| (digits.len() == MAX_EXACT_JSON_INTEGER_TEXT.len()
&& digits.as_str() > MAX_EXACT_JSON_INTEGER_TEXT)
{
return None;
}
let normalized = if negative {
format!("-{digits}")
} else {
digits
};
if normalized == raw {
None
} else {
normalized.parse().ok()
}
}
pub(super) fn exact_nonnegative_sequence(value: &Value) -> Option<u64> {
let number = value.as_number()?;
let sequence = number
.as_u64()
.or_else(|| normalize_integral_number(number)?.as_u64())?;
(sequence <= MAX_EXACT_JSON_INTEGER).then_some(sequence)
}
fn parse_bounded_decimal_exponent(raw: &str) -> Option<i64> {
if raw.is_empty() {
return Some(0);
}
let (sign, digits) = if let Some(value) = raw.strip_prefix('+') {
(1_i64, value)
} else if let Some(value) = raw.strip_prefix('-') {
(-1_i64, value)
} else {
(1_i64, raw)
};
if digits.is_empty() {
return None;
}
let digits = digits.trim_start_matches('0');
if digits.is_empty() {
return Some(0);
}
if digits.len() > 9 {
return None;
}
digits.parse::<i64>().ok().map(|value| sign * value)
}
fn resolve_local_schema_value_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
if reference == "#" {
return Some(root);
}
let pointer = reference.strip_prefix('#')?;
if !pointer.starts_with('/') {
return None;
}
root.pointer(pointer)
}
pub(super) fn schema_contains_integer(schema: &Value, depth: usize) -> bool {
let mut visited = BTreeSet::new();
schema_contains_reachable_integer(schema, schema, &mut visited, depth)
}
fn schema_contains_reachable_integer(
schema: &Value,
root: &Value,
visited_refs: &mut BTreeSet<String>,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
if schema_requires_integer(schema) {
return true;
}
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& visited_refs.insert(reference.to_owned())
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
&& schema_contains_reachable_integer(resolved, root, visited_refs, depth + 1)
{
return true;
}
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if schema
.get(keyword)
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.iter().any(|branch| {
schema_contains_reachable_integer(branch, root, visited_refs, depth + 1)
})
})
{
return true;
}
}
for keyword in ["items", "additionalProperties"] {
if schema.get(keyword).is_some_and(|child| {
schema_contains_reachable_integer(child, root, visited_refs, depth + 1)
}) {
return true;
}
}
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
properties.values().any(|property| {
schema_contains_reachable_integer(property, root, visited_refs, depth + 1)
})
})
}
@@ -1,6 +1,6 @@
//! Codex/OpenAI 工具声明与历史调用到 Grok 工具合同的归一化。
use super::response::{ToolIdentity, ToolKind};
use super::response::{ToolIdentity, ToolKind, retype_tool_item_id};
use super::*;
pub(super) struct ToolNormalizer {
@@ -9,7 +9,6 @@ pub(super) struct ToolNormalizer {
deferred_surfaces: Vec<String>,
client_search_tool: Option<Map<String, Value>>,
server_search_eager: bool,
native_shell: bool,
}
pub(super) struct NormalizedInputItems {
@@ -32,7 +31,6 @@ impl ToolNormalizer {
deferred_surfaces: Vec::new(),
client_search_tool: None,
server_search_eager: false,
native_shell: false,
}
}
@@ -48,7 +46,6 @@ impl ToolNormalizer {
deferred_surfaces: Vec::new(),
client_search_tool: None,
server_search_eager: false,
native_shell: false,
}
}
@@ -182,7 +179,7 @@ impl ToolNormalizer {
| "web_search_2025_08_26" => self.normalize_web_search_tool(tool, kind),
"mcp" => self.normalize_mcp_tool(tool, client_search, force),
"shell" => self.normalize_shell_tool(tool),
"local_shell" => self.normalize_legacy_local_shell_tool(tool),
"local_shell" => Err(GrokRequestEncodeError::InvalidRequestNormalization),
"apply_patch" => self.normalize_apply_patch_tool(tool),
"x_search" | "collections_search" | "file_search" | "code_execution"
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
@@ -310,17 +307,31 @@ impl ToolNormalizer {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
let identity = ToolIdentity::new(ToolKind::Custom, namespace, name);
let argument_field = identity.custom_argument_field();
let description = if identity.is_root_custom_apply_patch() {
"The apply_patch tool edits files using Codex patch format. Provide the complete raw patch text in the patch string field.".to_owned()
} else {
let mut description = string_field(tool, "description").trim().to_owned();
if !description.is_empty() {
description.push('\n');
}
description.push_str("Provide the custom tool input in the input string field.");
description
};
let argument_field = "input";
// function 不支持 custom 的 grammar 字段,必须把约束连同原说明交给模型。
let mut description = format!(
"This function wraps a custom tool. The original instructions below describe the \
decoded {argument_field} string.\n{}",
string_field(tool, "description").trim()
);
if let Some(format) = tool.get("format").and_then(Value::as_object)
&& let Some(definition) = format.get("definition").and_then(Value::as_str)
{
description.push_str("\nInput grammar (syntax: ");
description.push_str(string_field(format, "syntax"));
description.push_str("):\n");
description.push_str(definition);
}
description.push_str(&format!(
"\nProvide the complete raw tool input in the {argument_field} string field. \
Encode it as a JSON string once; decoded newlines must be actual newline characters."
));
if identity.is_root_custom_apply_patch() {
description.push_str(
" The patch must start with exactly '*** Begin Patch' and end with exactly \
'*** End Patch', each on its own line. Do not add Markdown fences.",
);
}
let alias = self.alias(identity);
Ok(vec![json_object([
("type", Value::String("function".to_owned())),
@@ -392,136 +403,6 @@ pub(super) fn optional_array(
}
}
/// Grok Build 要求 function parameters 的根节点必为 object;Codex 会为可选参数
/// 生成 `object | null`,这里只移除根节点 nullability,嵌套字段保持原样。
pub(super) fn normalize_function_parameters_root(
value: &Value,
) -> Result<Option<Value>, GrokRequestEncodeError> {
let Some(schema) = value.as_object() else {
return Ok(None);
};
let mut normalized = schema.clone();
let mut changed = false;
if let Some(types) = normalized.get("type").and_then(Value::as_array).cloned() {
let removed_null = types.iter().any(|value| value.as_str() == Some("null"));
if removed_null {
let remaining = types
.into_iter()
.filter(|value| value.as_str() != Some("null"))
.collect::<Vec<_>>();
if remaining.len() != 1 || remaining[0].as_str() != Some("object") {
return Err(invalid_function_parameters_root());
}
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
changed = true;
}
}
for keyword in ["anyOf", "oneOf"] {
let Some(branches) = normalized.get(keyword).and_then(Value::as_array).cloned() else {
continue;
};
let removed_null = branches.iter().any(is_null_only_schema);
if !removed_null {
continue;
}
let remaining = branches
.into_iter()
.filter(|branch| !is_null_only_schema(branch))
.collect::<Vec<_>>();
if remaining.is_empty()
|| remaining.iter().any(|branch| {
branch.as_object().is_none_or(|branch| {
!is_object_root_schema(branch, &normalized, &mut BTreeSet::new())
})
})
{
return Err(invalid_function_parameters_root());
}
if remaining.len() == 1 && normalized.len() == 1 {
normalized = remaining[0]
.as_object()
.cloned()
.ok_or_else(invalid_function_parameters_root)?;
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
} else {
normalized.insert(keyword.to_owned(), Value::Array(remaining));
normalized.insert("type".to_owned(), Value::String("object".to_owned()));
}
changed = true;
}
Ok(changed.then_some(Value::Object(normalized)))
}
pub(super) fn is_null_only_schema(value: &Value) -> bool {
let Some(schema) = value.as_object() else {
return false;
};
match schema.get("type") {
Some(Value::String(kind)) => kind == "null",
Some(Value::Array(types)) if !types.is_empty() => {
types.iter().all(|kind| kind.as_str() == Some("null"))
}
_ => false,
}
}
pub(super) fn is_object_root_schema(
schema: &Map<String, Value>,
root: &Map<String, Value>,
visited: &mut BTreeSet<String>,
) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => return kind == "object",
Some(Value::Array(types)) if !types.is_empty() => {
return types.iter().all(|kind| kind.as_str() == Some("object"));
}
Some(_) => return false,
None => {}
}
if schema.contains_key("properties") {
return true;
}
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return false;
};
if !visited.insert(reference.to_owned()) {
return false;
}
resolve_local_schema_ref(root, reference)
.is_some_and(|resolved| is_object_root_schema(resolved, root, visited))
}
pub(super) fn resolve_local_schema_ref<'a>(
root: &'a Map<String, Value>,
reference: &str,
) -> Option<&'a Map<String, Value>> {
if reference == "#" {
return Some(root);
}
let path = reference.strip_prefix("#/")?;
let mut segments = path.split('/');
let first = decode_json_pointer_segment(segments.next()?);
let mut current = root.get(&first)?;
for segment in segments {
let segment = decode_json_pointer_segment(segment);
current = current.as_object()?.get(&segment)?;
}
current.as_object()
}
pub(super) fn decode_json_pointer_segment(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
pub(super) const fn invalid_function_parameters_root() -> GrokRequestEncodeError {
GrokRequestEncodeError::InvalidRequestField {
field: "tools[].parameters",
}
}
pub(super) fn inspect_tool_search(tools: &[Value]) -> Result<bool, GrokRequestEncodeError> {
let mut client_search = false;
let mut server_search = false;
@@ -560,6 +441,7 @@ pub(super) fn json_object<const N: usize>(entries: [(&str, Value); N]) -> Value
pub(super) fn without_defer_loading(tool: &Map<String, Value>) -> Map<String, Value> {
let mut converted = tool.clone();
converted.remove("defer_loading");
converted.remove("external_web_access");
converted
}
@@ -700,30 +582,9 @@ impl ToolNormalizer {
&mut self,
tool: &Map<String, Value>,
) -> Result<Vec<Value>, GrokRequestEncodeError> {
if self.response.legacy_local_shell {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
self.native_shell = true;
Ok(vec![Value::Object(without_defer_loading(tool))])
}
fn normalize_legacy_local_shell_tool(
&mut self,
_tool: &Map<String, Value>,
) -> Result<Vec<Value>, GrokRequestEncodeError> {
if self.native_shell || self.response.legacy_local_shell {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
self.response.legacy_local_shell = true;
Ok(vec![json_object([
("type", Value::String("shell".to_owned())),
(
"environment",
json_object([("type", Value::String("local".to_owned()))]),
),
])])
}
fn normalize_apply_patch_tool(
&mut self,
_tool: &Map<String, Value>,
@@ -1081,14 +942,9 @@ impl ToolNormalizer {
rewritten.push(Value::Object(normalize_apply_patch_output_input(item)?));
}
"agent_message" => rewritten.push(normalize_agent_message_input(item)),
"local_shell_call" => {
rewritten.push(Value::Object(normalize_legacy_local_shell_call_input(
item,
)?));
"local_shell_call" | "local_shell_call_output" => {
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
}
"local_shell_call_output" => rewritten.push(Value::Object(
normalize_legacy_local_shell_output_input(item)?,
)),
"shell_call_output" => {
rewritten.push(Value::Object(normalize_shell_call_output_input(item)?));
}
@@ -1244,6 +1100,7 @@ impl ToolNormalizer {
converted.insert("call_id".to_owned(), Value::String(call_id.to_owned()));
converted.insert("name".to_owned(), Value::String(name));
converted.insert("arguments".to_owned(), Value::String(arguments));
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
@@ -1259,11 +1116,9 @@ impl ToolNormalizer {
let call_id = required_trimmed_string(item, "call_id")?;
let namespace = string_field(item, "namespace").trim();
let identity = ToolIdentity::new(ToolKind::Custom, namespace, name);
let arguments = serde_json::to_string(&json_object([(
identity.custom_argument_field(),
Value::String(input.to_owned()),
)]))
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization)?;
let arguments =
serde_json::to_string(&json_object([("input", Value::String(input.to_owned()))]))
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization)?;
let alias = self.alias(identity);
let mut converted = item.clone();
strip_grok_internal_keys(&mut converted);
@@ -1273,6 +1128,7 @@ impl ToolNormalizer {
converted.insert("call_id".to_owned(), Value::String(call_id.to_owned()));
converted.insert("name".to_owned(), Value::String(alias));
converted.insert("arguments".to_owned(), Value::String(arguments));
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
@@ -1356,12 +1212,19 @@ impl ToolNormalizer {
}
let arguments = encode_function_arguments(item.get("arguments"))?;
let alias = self.alias(ToolIdentity::new(ToolKind::ToolSearch, "", "tool_search"));
Ok(Map::from_iter([
let mut converted = Map::from_iter([
("type".to_owned(), Value::String("function_call".to_owned())),
("call_id".to_owned(), Value::String(call_id.to_owned())),
("name".to_owned(), Value::String(alias)),
("arguments".to_owned(), Value::String(arguments)),
]))
]);
for field in ["id", "status"] {
if let Some(value) = item.get(field).filter(|value| !value.is_null()) {
converted.insert(field.to_owned(), value.clone());
}
}
retype_tool_item_id(&mut converted, "id", "function_call");
Ok(converted)
}
fn normalize_tool_search_output(
@@ -1463,277 +1326,6 @@ pub(super) fn required_trimmed_string<'a>(
.ok_or(GrokRequestEncodeError::InvalidRequestNormalization)
}
pub(super) fn encode_function_arguments(
value: Option<&Value>,
) -> Result<String, GrokRequestEncodeError> {
match value {
Some(Value::String(value)) => Ok(value.clone()),
None | Some(Value::Null) => Ok("{}".to_owned()),
Some(value) => serde_json::to_string(value)
.map_err(|_| GrokRequestEncodeError::InvalidRequestNormalization),
}
}
pub(super) fn normalize_function_arguments(arguments: &str, schema: &Value) -> Option<String> {
if arguments.trim().is_empty() || !schema.is_object() {
return None;
}
let mut value = serde_json::from_str::<Value>(arguments).ok()?;
if !normalize_argument_value(&mut value, schema, schema, 0) {
return None;
}
serde_json::to_string(&value).ok()
}
pub(super) fn normalize_argument_value(
value: &mut Value,
schema: &Value,
root: &Value,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
let mut changed = false;
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
{
changed |= normalize_argument_value(value, resolved, root, depth + 1);
}
for keyword in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = schema.get(keyword).and_then(Value::as_array) {
for branch in branches {
changed |= normalize_argument_value(value, branch, root, depth + 1);
}
}
}
if schema_requires_integer(schema)
&& let Value::Number(number) = value
&& let Some(normalized) = normalize_integral_number(number)
{
*number = normalized;
return true;
}
match value {
Value::Object(object) => {
let properties = schema.get("properties").and_then(Value::as_object);
let additional = schema
.get("additionalProperties")
.filter(|value| value.is_object());
for (key, item) in object {
let property = properties
.and_then(|properties| properties.get(key))
.or(additional);
if let Some(property) = property {
changed |= normalize_argument_value(item, property, root, depth + 1);
}
}
}
Value::Array(items) => {
let prefix = schema.get("prefixItems").and_then(Value::as_array);
let item_schema = schema.get("items").filter(|value| value.is_object());
for (index, item) in items.iter_mut().enumerate() {
let schema = prefix
.and_then(|prefix| prefix.get(index))
.filter(|value| value.is_object())
.or(item_schema);
if let Some(schema) = schema {
changed |= normalize_argument_value(item, schema, root, depth + 1);
}
}
}
_ => {}
}
changed
}
pub(super) fn schema_requires_integer(schema: &Map<String, Value>) -> bool {
match schema.get("type") {
Some(Value::String(kind)) => kind == "integer",
Some(Value::Array(kinds)) => {
let mut integer = false;
for kind in kinds.iter().filter_map(Value::as_str) {
if kind == "number" {
return false;
}
integer |= kind == "integer";
}
integer
}
_ => false,
}
}
pub(super) fn normalize_integral_number(number: &serde_json::Number) -> Option<serde_json::Number> {
let raw = number.to_string();
if raw.len() > MAX_JSON_NUMBER_TEXT_BYTES || !raw.contains(['.', 'e', 'E']) {
return None;
}
let (mantissa, exponent_text) = raw.find(['e', 'E']).map_or((raw.as_str(), ""), |index| {
(&raw[..index], &raw[index + 1..])
});
let (negative, mantissa) = mantissa
.strip_prefix('-')
.map_or((false, mantissa), |value| (true, value));
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let mut digits = format!("{whole}{fraction}")
.trim_start_matches('0')
.to_owned();
if digits.is_empty() {
return "0".parse().ok().filter(|_| raw != "0");
}
let exponent = parse_bounded_decimal_exponent(exponent_text)?;
let decimal_shift = exponent - i64::try_from(fraction.len()).ok()?;
if decimal_shift < 0 {
let fractional_digits = usize::try_from(-decimal_shift).ok()?;
if fractional_digits > digits.len()
|| !digits[digits.len() - fractional_digits..]
.bytes()
.all(|byte| byte == b'0')
{
return None;
}
digits.truncate(digits.len() - fractional_digits);
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() {
return "0".parse().ok();
}
digits = trimmed.to_owned();
} else if decimal_shift > 0 {
let shift = usize::try_from(decimal_shift).ok()?;
if shift
> MAX_EXACT_JSON_INTEGER_TEXT
.len()
.saturating_sub(digits.len())
{
return None;
}
digits.extend(std::iter::repeat_n('0', shift));
}
if digits.len() > MAX_EXACT_JSON_INTEGER_TEXT.len()
|| (digits.len() == MAX_EXACT_JSON_INTEGER_TEXT.len()
&& digits.as_str() > MAX_EXACT_JSON_INTEGER_TEXT)
{
return None;
}
let normalized = if negative {
format!("-{digits}")
} else {
digits
};
if normalized == raw {
None
} else {
normalized.parse().ok()
}
}
pub(super) fn exact_nonnegative_sequence(value: &Value) -> Option<u64> {
let number = value.as_number()?;
let sequence = number
.as_u64()
.or_else(|| normalize_integral_number(number)?.as_u64())?;
(sequence <= MAX_EXACT_JSON_INTEGER).then_some(sequence)
}
pub(super) fn parse_bounded_decimal_exponent(raw: &str) -> Option<i64> {
if raw.is_empty() {
return Some(0);
}
let (sign, digits) = if let Some(value) = raw.strip_prefix('+') {
(1_i64, value)
} else if let Some(value) = raw.strip_prefix('-') {
(-1_i64, value)
} else {
(1_i64, raw)
};
if digits.is_empty() {
return None;
}
let digits = digits.trim_start_matches('0');
if digits.is_empty() {
return Some(0);
}
if digits.len() > 9 {
return None;
}
digits.parse::<i64>().ok().map(|value| sign * value)
}
pub(super) fn resolve_local_schema_value_ref<'a>(
root: &'a Value,
reference: &str,
) -> Option<&'a Value> {
if reference == "#" {
return Some(root);
}
let pointer = reference.strip_prefix('#')?;
if !pointer.starts_with('/') {
return None;
}
root.pointer(pointer)
}
pub(super) fn schema_contains_integer(schema: &Value, depth: usize) -> bool {
let mut visited = BTreeSet::new();
schema_contains_reachable_integer(schema, schema, &mut visited, depth)
}
pub(super) fn schema_contains_reachable_integer(
schema: &Value,
root: &Value,
visited_refs: &mut BTreeSet<String>,
depth: usize,
) -> bool {
if depth > 64 {
return false;
}
let Some(schema) = schema.as_object() else {
return false;
};
if schema_requires_integer(schema) {
return true;
}
if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
&& visited_refs.insert(reference.to_owned())
&& let Some(resolved) = resolve_local_schema_value_ref(root, reference)
&& schema_contains_reachable_integer(resolved, root, visited_refs, depth + 1)
{
return true;
}
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if schema
.get(keyword)
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.iter().any(|branch| {
schema_contains_reachable_integer(branch, root, visited_refs, depth + 1)
})
})
{
return true;
}
}
for keyword in ["items", "additionalProperties"] {
if schema.get(keyword).is_some_and(|child| {
schema_contains_reachable_integer(child, root, visited_refs, depth + 1)
}) {
return true;
}
}
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
properties.values().any(|property| {
schema_contains_reachable_integer(property, root, visited_refs, depth + 1)
})
})
}
pub(super) fn encode_tool_output(value: Option<&Value>) -> Result<String, GrokRequestEncodeError> {
match value {
None | Some(Value::Null) => Ok(String::new()),
@@ -338,6 +338,31 @@ async fn xai_admin_provider_projects_cached_quota_models_and_canonical_export()
.await
.expect("cached quota");
assert!(quota.windows.is_empty());
store
.compare_and_swap_quota(gateway_core::account::QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(
serde_json::from_value(serde_json::json!({
"subscriptionTier": "Free",
"config": {}
}))
.expect("quota document"),
),
observed_at: SystemTime::now(),
state: account.quota(),
})
.await
.expect("store subscription snapshot");
let quota = admin
.quota(ProviderQuotaRequest {
account_id: account.id().clone(),
refresh: false,
rolling_usage: None,
})
.await
.expect("cached subscription");
assert_eq!(quota.plan_type.as_deref(), Some("Free"));
let models = admin
.models(account.id(), false)
.await
@@ -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) =
@@ -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
View File
@@ -1,3 +1,4 @@
b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.sql
78c427c17d8b5759405f1629a80ceb624012cb6b31371b762078b000266d86c6 0002_client_key_budgets.sql
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.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;
-10
View File
@@ -313,16 +313,6 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build --wait
```
仓库维护者发布新版本时必须从干净且已同步上游的分支运行:
```bash
release/publish X.Y.Z
```
将 `X.Y.Z` 替换为待发布版本。脚本需要 Git、Ruby 和已登录的 GitHub CLI,
负责更新 `release/version.yaml`、创建版本提交和带注释 tag,并原子推送分支与 tag。
它不会登录任何服务器,也不会拉取镜像或调用管理端在线更新。
源码提交、仓库发版和运行实例升级是三种独立状态:本地 commit 不等于 Release,Release/tag 和镜像
已生成也不等于实例已升级。判断某项修复是否在线前,应先通过管理端版本接口或容器 image digest
确认运行实例的实际 revision;实例只有在执行上面的 Compose pull/up,或成功完成管理端在线更新后
+6 -7
View File
@@ -437,8 +437,6 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
| `POST` | `/api/admin/client-keys/enable` | `{ id }` | 启用 |
| `POST` | `/api/admin/client-keys/disable` | `{ id }` | 禁用 |
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
| `GET` | `/api/admin/client-keys/unresolved-charges` | `id` | 按时间读取最早的 200 笔待核账请求 |
| `POST` | `/api/admin/client-keys/reconcile-charge` | `{ id, requestId, amountUsd, reason }` | 管理员确认未知费用并记录审计 |
创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选
`dailyLimitUsd` 和 `weeklyLimitUsd`,更新请求再增加
@@ -452,7 +450,8 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
`maxConcurrency` 和 `requestsPerMinute` 是非负整数,零表示不限。
列表增加 `dailyLimitUsd`、`weeklyLimitUsd`、`dailyUsedUsd`、`weeklyUsedUsd`(均为字符串)、
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)与 `unresolvedRequests`。
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。
管理端日/周金额显示两位小数,悬停可查看原始值;记账和限额比较保留完整精度。
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
@@ -461,12 +460,12 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
任一已结算金额达到限额后拒绝新请求,已准入请求可完成并使金额超过阈值。
HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly_budget_exceeded`,
并附 `Retry-After`;WebSocket 每次 `response.create` 执行相同检查并返回协议错误事件。
已发送但费用不明、或超过请求期限仍未结算的请求使受限 Key 返回 `key_budget_unresolved`。
只累计上游上报或按用量与模型价格计算出的 USD 费用;无法取得费用的尝试按零累计,
保留错误和用量诊断,不产生待核账记录或阻断。内部重试中已经取得的费用仍会累计。
预算存储不可用时返回 `503`、`key_budget_unavailable`。
核账的 `amountUsd` 使用相同金额格式,`reason` 为 1–1024 字节的非空原因。只能处理该 Key 下未知或
已超过期限的请求;相同金额重复提交不重复计费,修改已结算金额返回 `409`。账本独立于使用统计日志,
记录保留至删除 Key,不受 `usageRetentionDays` 影响。
自动结算按网关请求 ID 幂等执行。账本独立于使用统计日志,记录保留至删除 Key,
不受 `usageRetentionDays` 影响。
## 8. 运行设置
+16 -10
View File
@@ -135,7 +135,7 @@ sequenceDiagram
A->>E: Operation + client context
E->>E: freeze snapshot and compile routing plan
E->>S: enqueue request / attempt observations
E->>S: check Key budget and persist pending charge
E->>S: check Key budget against recorded usage
E->>P: one candidate, one credential, one attempt
P-->>E: cold canonical stream + raw wire
E->>E: enforce send and downstream commit barriers
@@ -250,23 +250,23 @@ Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/
### Client Key 限额与结算
日金额、七天金额、并发和 RPM 按 Client Key 跨账号、跨 Provider 合计,零表示不限;修改限额不重置已用金额。
Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置及带审计的费用核账。
Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置。
- 日窗口按北京时间零点划分;七天窗口从首次准入当天零点开始,到期后由下一次使用重新开启,不固定为周一。
- 金额优先使用 Provider 上报的 USD,否则按现有模型价格估算;订阅账号的估算费用不代表上游订阅账单。
费用归属请求完成时间,跨日请求计入完成日,延迟写入或核账仍保留原完成时间。
费用归属请求完成时间,跨日请求计入完成日,延迟写入仍保留原完成时间。
- 准入检查已结算金额,不预占未来费用。达到任一金额阈值后拒绝新请求,已准入请求仍可完成并使总额超过阈值。
- SSE 持有并发名额直到终态;WebSocket 每个 `response.create` 独立准入,空闲连接不占名额。
Core 在每次请求开始时重新鉴权并冻结当前策略,既有连接也应用已发布的限额与授权范围变更。
换号和内部重试复用同一名额,Key 与账号并发上限同时生效;预算拒绝或启动失败释放名额。
每次计费请求在发送上游前持久化待结算记录。完成、失败、取消和断连使用同一结算路径,以网关请求 ID
保证幂等;明确未发送的失败按零结算,已发送或可能已发送但没有可信费用的请求保留未知状态。
未知费用或超过请求期限仍未结算的记录阻止受限 Key 接受新的计费请求,需管理员确认金额并填写核账原因;
零金额也必须显式确认。限额或核账阻断不会修改 Key 的管理员启停状态。
完成、失败、取消和断连使用同一结算路径,在结束时以网关请求 ID 幂等累计已取得费用。
缺少用量或价格、无法取得 USD 费用的尝试按零累计,不创建待核账记录,也不会阻断 Key;
内部重试中已经取得的费用仍须累计。发送状态只决定重放是否安全,不能据此推断费用。
请求日志继续保留真实错误、用量与费用来源,账本按零累计不表示上游实际免费。
结算写入失败时,进程内保留精确费用,在同一 Key 下次请求前重试;进程崩溃后依靠持久化待结算记录要求核账,
不能把遗失费用按零放行。PostgreSQL 不可用时拒绝所有新的计费请求,Redis 继续管理并发/RPM 租约。
结算写入失败时,进程内保留精确费用,在同一 Key 下次请求前重试;进程退出后无法恢复的费用不会形成欠账。
PostgreSQL 不可用时拒绝所有新的计费请求,Redis 继续管理并发/RPM 租约。
账本独立于可丢弃的请求观测日志,日志清理不重置金额;费用事件保留至删除 Key,已有日志不会回填为账本费用。
字段与错误合同见 [Client Key API](api.md#7-client-key)。
@@ -318,7 +318,13 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON,导入识别 camelCase 和官方 `auth.json` 的 snake_case token 字段;
RT-only 导入先换取 AT,AT-only 导入没有
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。刷新额度时同步查询官方实时订阅,
只把套餐事实写入现有 quota JSON。明确无付费订阅的个人账号显示 Free;查询失败、缺失字段或
团队身份不推断为 Free,订阅查询失败不影响额度观测。
Codex custom 工具统一以 function 的 `input` 字符串包装,保留说明与 grammar;响应恢复
原工具类型及对应 item ID,`call_id` 始终保持配对。转换仅处理协议字段,超限或转换失败终止流。
默认 `store: false` 的续接使用现有会话 owner 重放完整历史;上一轮要求上游存储且指令未变时,
原生续接移除本轮 `instructions`,避免 Grok 拒绝与 `previous_response_id` 同传。
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
- quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与
目录 cache 隔离,不创建套餐专属状态机。
-16
View File
@@ -17,7 +17,6 @@ export interface ApiKey {
weeklyUsedUsd: string
dailyResetsAt: string | null
weeklyResetsAt: string | null
unresolvedRequests: number
createdAt: string
updatedAt: string
lastUsedAt: string | null
@@ -47,21 +46,6 @@ export interface ApiKeyMutationResponse {
id: string
}
export interface UnresolvedClientCharge {
requestId: string
startedAt: string
completedAt: string | null
state: string
}
export function getUnresolvedClientCharges(id: string) {
return request<{ items: UnresolvedClientCharge[] }>({ url: '/api/admin/client-keys/unresolved-charges', method: 'GET', params: { id } })
}
export function reconcileClientCharge(data: { id: string, requestId: string, amountUsd: string, reason: string }) {
return request<{ reconciled: boolean }>({ url: '/api/admin/client-keys/reconcile-charge', method: 'POST', data })
}
// 请求参数类型:仅定义 API 边界的形状,调用方不依赖显式声明。
interface ApiKeyListParams {
cursor?: string
@@ -1,11 +1,8 @@
<script setup lang="ts">
import type { ApiKey } from '@/api'
import { ClipboardCheck } from '@lucide/vue'
import { computed } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
const props = defineProps<{ apiKey: ApiKey }>()
const emit = defineEmits<{ reconcile: [apiKey: ApiKey] }>()
const windows = computed(() => [
{ label: '日', used: props.apiKey.dailyUsedUsd, limit: props.apiKey.dailyLimitUsd, reset: props.apiKey.dailyResetsAt },
{ label: '周', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
@@ -14,7 +11,8 @@ function resetLabel(value: string | null) {
return value ? `重置:${new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}(北京时间)` : ''
}
function amount(value: string) {
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 10 })
// 列表显示两位小数,悬停查看完整金额;记账与限额比较仍使用原始精度。
return Number(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
@@ -26,11 +24,5 @@ function amount(value: string) {
${{ amount(window.used) }} / {{ Number(window.limit) === 0 ? '∞' : `$${amount(window.limit)}` }}
</span>
</div>
<div v-if="apiKey.unresolvedRequests" class="flex items-center gap-1 text-cp-warning">
<span>待核账 {{ apiKey.unresolvedRequests }} 笔</span>
<BaseIconButton variant="ghost" size="sm" label="费用核账" @click.stop="emit('reconcile', apiKey)">
<ClipboardCheck class="size-3.5" />
</BaseIconButton>
</div>
</div>
</template>
@@ -1,124 +0,0 @@
<script setup lang="ts">
import type { ApiKey, UnresolvedClientCharge } from '@/api'
import { computed, ref, watch } from 'vue'
import { getUnresolvedClientCharges, reconcileClientCharge } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import { toast } from '@/components/base/BaseToast'
import { errorMessage } from '@/utils/async'
const props = defineProps<{ apiKey: ApiKey | null }>()
const emit = defineEmits<{ reconciled: [] }>()
const open = defineModel<boolean>({ required: true })
const events = ref<UnresolvedClientCharge[]>([])
const requestId = ref('')
const amount = ref('')
const reason = ref('')
const loading = ref(false)
const saving = ref(false)
const error = ref('')
let generation = 0
const options = computed(() => events.value.map(event => ({ value: event.requestId, label: event.requestId })))
const selected = computed(() => events.value.find(event => event.requestId === requestId.value))
const valid = computed(() => /^\d{1,10}(?:\.\d{1,10})?$/.test(amount.value.trim()) && reason.value.trim().length > 0 && reason.value.length <= 1024)
async function load() {
const id = props.apiKey?.id
const version = ++generation
if (!id || !open.value)
return
loading.value = true
error.value = ''
events.value = []
try {
const result = await getUnresolvedClientCharges(id)
if (version !== generation)
return
events.value = result.items
requestId.value = result.items[0]?.requestId ?? ''
}
catch (failure) {
if (version === generation)
error.value = errorMessage(failure, '待核账请求读取失败')
}
finally {
if (version === generation)
loading.value = false
}
}
async function save() {
const id = props.apiKey?.id
if (!id || !requestId.value || !valid.value || saving.value)
return
saving.value = true
try {
await reconcileClientCharge({ id, requestId: requestId.value, amountUsd: amount.value.trim(), reason: reason.value.trim() })
toast.success('费用已核账')
emit('reconciled')
await load()
if (events.value.length === 0 && !error.value)
open.value = false
}
catch (failure) {
toast.error(errorMessage(failure, '核账失败'))
}
finally {
saving.value = false
}
}
watch([open, () => props.apiKey?.id], () => {
amount.value = ''
reason.value = ''
void load()
})
watch(requestId, () => {
amount.value = ''
reason.value = ''
})
</script>
<template>
<BaseModal v-model="open" title="费用核账" :description="apiKey?.name" size="md" :dismissible="!saving">
<p v-if="loading" class="text-cp-text-tertiary">
读取中…
</p>
<div v-else-if="error" class="grid gap-3">
<p class="text-cp-error">
{{ error }}
</p>
<BaseButton variant="secondary" @click="load">
重试
</BaseButton>
</div>
<div v-else-if="events.length" class="grid gap-4">
<BaseFormItem label="待核账请求">
<BaseSelect v-model="requestId" :options="options" :disabled="saving" />
</BaseFormItem>
<p v-if="selected" class="m-0 text-xs text-cp-text-tertiary">
{{ new Date(selected.startedAt).toLocaleString('zh-CN') }}
</p>
<BaseFormItem label="确认费用(USD)" required>
<BaseInput v-model="amount" aria-label="确认费用(USD)" type="number" min="0" step="any" :disabled="saving" />
</BaseFormItem>
<BaseFormItem label="核账原因" required>
<BaseInput v-model="reason" aria-label="核账原因" :maxlength="1024" :disabled="saving" />
</BaseFormItem>
</div>
<p v-else class="text-cp-text-tertiary">
暂无待核账请求
</p>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" :loading="saving" :disabled="!valid || !requestId || loading || Boolean(error)" @click="save">
确认核账
</BaseButton>
</template>
</BaseModal>
</template>
+1 -12
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { ApiKey } from '@/api'
import { ref, watch } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -17,7 +16,6 @@ import ApiKeyCreateModal from './components/ApiKeyCreateModal.vue'
import ApiKeyFilters from './components/ApiKeyFilters.vue'
import ApiKeyIdentityCell from './components/ApiKeyIdentityCell.vue'
import ApiKeyPrefixCell from './components/ApiKeyPrefixCell.vue'
import ApiKeyReconcileModal from './components/ApiKeyReconcileModal.vue'
import ApiKeyScopeCell from './components/ApiKeyScopeCell.vue'
import ApiKeyStatusBadge from './components/ApiKeyStatusBadge.vue'
import ApiKeyUseModal from './components/ApiKeyUseModal.vue'
@@ -27,14 +25,6 @@ import { useApiKeyUse } from './composables/useApiKeyUse'
import { apiKeyColumns } from './constants'
const selectedIds = ref<Set<string>>(new Set())
const reconcileKey = ref<ApiKey | null>(null)
const showReconcile = ref(false)
function openReconcile(key: ApiKey) {
reconcileKey.value = key
showReconcile.value = true
}
const {
loading,
apiKeys,
@@ -168,7 +158,7 @@ watch(
<ApiKeyScopeCell :api-key="row" />
</template>
<template #budget="{ row }">
<ApiKeyBudgetCell :api-key="row" @reconcile="openReconcile" />
<ApiKeyBudgetCell :api-key="row" />
</template>
<template #limits="{ row }">
<dl class="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs tabular-nums">
@@ -229,7 +219,6 @@ watch(
@save="requestSave"
@import-ccs="importCreatedKeyToCcs"
/>
<ApiKeyReconcileModal v-model="showReconcile" :api-key="reconcileKey" @reconciled="loadApiKeys" />
<ApiKeyUseModal
v-model="showUseKeyModal"
+17
View File
@@ -0,0 +1,17 @@
# v3.3.32
## Fixes
- Remove manual charge reconciliation. Requests without available usage or pricing no longer block API keys; known costs from internal retries still count toward spending limits.
- Correct xAI subscription plan labels using current subscription information, without treating failed subscription lookups as Free accounts.
- Fix Codex custom tool calls, tool output pairing, and conversation continuations through xAI.
- Distinguish missing OpenAI WebSocket closing handshakes from TCP resets in connection diagnostics.
## Improvements
- Display daily and weekly API key amounts with two decimal places. Hover to see the full amount; accounting and limit checks retain their original precision.
- Include version-specific update notes on release pages.
## Upgrade
The database migration automatically settles legacy pending and unknown charges at zero and removes reconciliation state. Previously recorded costs and daily/weekly totals are preserved. Request errors and usage diagnostics remain available.
+14
View File
@@ -3,6 +3,7 @@ set -euo pipefail
APP_NAME="codex-proxy-rs"
VERSION_FILE="release/version.yaml"
NOTES_FILE="release/notes.md"
TAG_PREFIX="v"
SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
SIGNATURE="Co-authored-by: Codex <noreply@openai.com>"
@@ -14,6 +15,9 @@ Usage:
Example:
release/publish 0.1.4
Before publishing, replace release/notes.md with the new version's notes and
commit it. Its first line must be "# v<version>".
USAGE
}
@@ -103,6 +107,16 @@ current_version="${current_version#v}"
tag="${TAG_PREFIX}${version}"
git ls-files --error-unmatch "$NOTES_FILE" >/dev/null 2>&1 || die "$NOTES_FILE is not tracked"
ruby - "$NOTES_FILE" "$tag" <<'RUBY'
path, tag = ARGV
lines = File.readlines(path)
unless lines.first&.strip == "# #{tag}" && !lines.drop(1).join.strip.empty?
warn "#{path}: replace the contents with notes for #{tag}, starting with '# #{tag}'"
exit 1
end
RUBY
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
die "local tag already exists: $tag"
fi
+1 -1
View File
@@ -1 +1 @@
version: "3.3.31"
version: "3.3.32"
+2 -1
View File
@@ -88,4 +88,5 @@ docker compose -f deploy/compose.yaml config --quiet
- 提交前检查 status、cached/unstaged diff 和 `git diff --check`。
- 使用简短 Conventional Commit subject;按用户要求添加 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布权威是 `release/version.yaml` 与带注释的 `vX.Y.Z` tag。
- 正式发布使用 `release/publish <version>`;提交、Release 和实例升级是三个独立状态。
- 发版前替换并提交 `release/notes.md`,首行必须为目标版本的 `# vX.Y.Z`,正文只记录本次更新。
- 正式发布使用 `release/publish <version>`;工作流读取 tag 中的发版说明。提交、Release 和实例升级是三个独立状态。