mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90dd5b5a57 | ||
|
|
5ad9378b33 | ||
|
|
4a413f5bb7 | ||
|
|
0bab566d04 | ||
|
|
9b19cf083a |
@@ -88,6 +88,13 @@ jobs:
|
||||
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
|
||||
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
|
||||
|
||||
notes_path = "release/notes.md"
|
||||
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
|
||||
notes = File.readlines(notes_path)
|
||||
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
|
||||
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
|
||||
end
|
||||
|
||||
platforms = platforms_data["platforms"]
|
||||
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
|
||||
|
||||
@@ -643,15 +650,10 @@ jobs:
|
||||
|
||||
cosign sign --yes "${images[@]}"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
|
||||
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
body: |
|
||||
- name: Prepare release notes
|
||||
shell: bash
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
|
||||
@@ -672,6 +674,26 @@ jobs:
|
||||
|
||||
- [GitHub Repository](https://github.com/${{ github.repository }})
|
||||
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
|
||||
installation = os.environ["INSTALLATION_NOTES"].strip()
|
||||
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
|
||||
output.write_text(f"{notes}\n\n{installation}\n")
|
||||
PY
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
|
||||
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
dist/release/*
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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
@@ -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
@@ -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 隔离,不创建套餐专属状态机。
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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 @@
|
||||
version: "3.3.31"
|
||||
version: "3.3.32"
|
||||
|
||||
@@ -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 和实例升级是三个独立状态。
|
||||
|
||||
Reference in New Issue
Block a user