mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor(gateway): finalize production API audit
Remove unused full-response transports and unconnected domain reservations while keeping test-only setup inside integration tests. Restore image-generation accounting and OpenAI WebSocket pool observations across the engine, store, and admin boundaries without changing the transparent OpenAI WS/SSE path. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -111,6 +111,10 @@ pub struct AccountModelUsage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: super::observability::CostCoverage,
|
||||
pub costs: Vec<AccountCost>,
|
||||
@@ -128,6 +132,10 @@ pub struct AccountUsage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: super::observability::CostCoverage,
|
||||
pub costs: Vec<AccountCost>,
|
||||
|
||||
@@ -443,6 +443,10 @@ pub struct DashboardAccountModelUsage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
@@ -467,6 +471,10 @@ pub struct DashboardAccountUsage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
@@ -506,6 +514,7 @@ pub struct UsageRecord {
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub upstream_send_state: String,
|
||||
pub downstream_committed_at: Option<DateTime<Utc>>,
|
||||
@@ -524,6 +533,8 @@ pub struct UsageRecord {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_source: String,
|
||||
pub cost_amount: Option<DecimalAmount>,
|
||||
@@ -545,6 +556,8 @@ pub struct UsageRecord {
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: bool,
|
||||
pub image_generation_requested: bool,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub deadline_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -236,6 +236,14 @@ pub struct AccountUsageView {
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cached_tokens_display: String,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_output_tokens_display: String,
|
||||
pub image_request_count: Option<u64>,
|
||||
pub image_request_count_display: String,
|
||||
pub image_request_failed_count: Option<u64>,
|
||||
pub image_request_failed_count_display: String,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub total_tokens_display: String,
|
||||
pub created_tokens: Option<u64>,
|
||||
@@ -267,6 +275,14 @@ pub struct ModelUsageView {
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cached_tokens_display: String,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_output_tokens_display: String,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_count_display: String,
|
||||
pub image_request_failed_count: u64,
|
||||
pub image_request_failed_count_display: String,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub total_tokens_display: String,
|
||||
pub billing_amount_usd: Option<String>,
|
||||
@@ -879,6 +895,12 @@ fn quota_local_usage(usage: &AccountUsage) -> Value {
|
||||
"outputTokensDisplay": display_optional_tokens(usage.output_tokens),
|
||||
"cachedTokens": usage.cached_tokens.unwrap_or_default(),
|
||||
"cachedTokensDisplay": display_optional_tokens(usage.cached_tokens),
|
||||
"imageInputTokens": usage.image_input_tokens.unwrap_or_default(),
|
||||
"imageInputTokensDisplay": display_optional_tokens(usage.image_input_tokens),
|
||||
"imageOutputTokens": usage.image_output_tokens.unwrap_or_default(),
|
||||
"imageOutputTokensDisplay": display_optional_tokens(usage.image_output_tokens),
|
||||
"imageRequestCount": usage.image_request_count,
|
||||
"imageRequestFailedCount": usage.image_request_failed_count,
|
||||
"totalTokens": total_tokens,
|
||||
"totalTokensDisplay": format_tokens(total_tokens),
|
||||
})
|
||||
@@ -908,6 +930,14 @@ fn account_usage_view(usage: Option<AccountUsage>, now: DateTime<Utc>) -> Accoun
|
||||
output_tokens_display: display_optional_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
|
||||
image_request_count: Some(usage.image_request_count),
|
||||
image_request_count_display: format_number(usage.image_request_count),
|
||||
image_request_failed_count: Some(usage.image_request_failed_count),
|
||||
image_request_failed_count_display: format_number(usage.image_request_failed_count),
|
||||
total_tokens: usage.total_tokens,
|
||||
total_tokens_display: display_optional_tokens(usage.total_tokens),
|
||||
created_tokens: usage.cache_write_tokens,
|
||||
@@ -967,6 +997,14 @@ fn account_model_usage_view(usage: AccountModelUsage, now: DateTime<Utc>) -> Mod
|
||||
output_tokens_display: display_optional_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_count_display: format_number(usage.image_request_count),
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
image_request_failed_count_display: format_number(usage.image_request_failed_count),
|
||||
total_tokens: usage.total_tokens,
|
||||
total_tokens_display: display_optional_tokens(usage.total_tokens),
|
||||
billing_amount_usd: usd.map(|cost| cost.amount.as_str().to_owned()),
|
||||
@@ -1006,6 +1044,14 @@ fn empty_account_usage() -> AccountUsageView {
|
||||
output_tokens_display: "—".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "—".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "—".to_owned(),
|
||||
image_output_tokens: None,
|
||||
image_output_tokens_display: "—".to_owned(),
|
||||
image_request_count: None,
|
||||
image_request_count_display: "—".to_owned(),
|
||||
image_request_failed_count: None,
|
||||
image_request_failed_count_display: "—".to_owned(),
|
||||
total_tokens: None,
|
||||
total_tokens_display: "—".to_owned(),
|
||||
created_tokens: None,
|
||||
|
||||
@@ -521,12 +521,16 @@ pub struct TokenDetailsView {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens_display: String,
|
||||
pub cache_write_tokens_display: String,
|
||||
pub reasoning_tokens_display: String,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens_display: String,
|
||||
pub total_tokens_display: String,
|
||||
}
|
||||
|
||||
@@ -595,6 +599,8 @@ pub struct UsageRecordView {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub message: String,
|
||||
pub metadata: UsageRecordMetadataView,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -623,6 +629,9 @@ pub struct UsageRecordMetadataView {
|
||||
pub protocol: String,
|
||||
pub logical_outcome: String,
|
||||
pub attempt_count: u64,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub image_generation_requested: bool,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示。
|
||||
@@ -1520,6 +1529,8 @@ fn token_details(record: &domain::UsageRecord) -> TokenDetailsView {
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
reasoning_tokens: record.reasoning_tokens,
|
||||
image_input_tokens: record.image_input_tokens,
|
||||
image_output_tokens: record.image_output_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
input_tokens_display: record
|
||||
.input_tokens
|
||||
@@ -1536,6 +1547,12 @@ fn token_details(record: &domain::UsageRecord) -> TokenDetailsView {
|
||||
reasoning_tokens_display: record
|
||||
.reasoning_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_input_tokens_display: record
|
||||
.image_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_output_tokens_display: record
|
||||
.image_output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
total_tokens_display: record
|
||||
.total_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
@@ -1687,11 +1704,16 @@ fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
reasoning_tokens: record.reasoning_tokens,
|
||||
image_input_tokens: record.image_input_tokens,
|
||||
image_output_tokens: record.image_output_tokens,
|
||||
message,
|
||||
metadata: UsageRecordMetadataView {
|
||||
protocol: record.protocol,
|
||||
logical_outcome: outcome.clone(),
|
||||
attempt_count: u64::from(record.attempt_count),
|
||||
websocket_pool: record.websocket_pool,
|
||||
image_generation_requested: record.image_generation_requested,
|
||||
image_generation_succeeded: record.image_generation_succeeded,
|
||||
},
|
||||
created_at: record.started_at,
|
||||
created_at_display: china_datetime(&record.started_at),
|
||||
|
||||
@@ -411,6 +411,15 @@ pub(super) fn decode_request_inner(
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| !tools.is_empty());
|
||||
let image_generation_requested =
|
||||
object
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| {
|
||||
tools.iter().any(|tool| {
|
||||
tool.get("type").and_then(Value::as_str) == Some("image_generation")
|
||||
})
|
||||
});
|
||||
let vision_requested = contains_type(object.get("input"), "input_image");
|
||||
let reasoning = canonical_reasoning(&object);
|
||||
let reasoning_requested = object.get("reasoning").is_some_and(Value::is_object);
|
||||
@@ -438,6 +447,7 @@ pub(super) fn decode_request_inner(
|
||||
})?;
|
||||
let mut request = GenerateRequest::from_protocol_payload(messages, payload)
|
||||
.with_estimated_context_tokens(input_token_estimate)
|
||||
.with_image_generation_requested(image_generation_requested)
|
||||
.with_provider_options(provider_options)
|
||||
.with_response_persistence(if store {
|
||||
ResponsePersistence::Store
|
||||
|
||||
@@ -71,6 +71,14 @@ mod response {
|
||||
output_tokens_display: "-".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "-".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "-".to_owned(),
|
||||
image_output_tokens: None,
|
||||
image_output_tokens_display: "-".to_owned(),
|
||||
image_request_count: None,
|
||||
image_request_count_display: "-".to_owned(),
|
||||
image_request_failed_count: None,
|
||||
image_request_failed_count_display: "-".to_owned(),
|
||||
total_tokens: None,
|
||||
total_tokens_display: "-".to_owned(),
|
||||
created_tokens: None,
|
||||
|
||||
@@ -172,12 +172,16 @@ mod response {
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: Some(3),
|
||||
input_tokens_display: "1".to_owned(),
|
||||
output_tokens_display: "2".to_owned(),
|
||||
cached_tokens_display: "-".to_owned(),
|
||||
cache_write_tokens_display: "-".to_owned(),
|
||||
reasoning_tokens_display: "-".to_owned(),
|
||||
image_input_tokens_display: "-".to_owned(),
|
||||
image_output_tokens_display: "-".to_owned(),
|
||||
total_tokens_display: "3".to_owned(),
|
||||
};
|
||||
let cursor = CursorWire {
|
||||
@@ -257,7 +261,7 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_route_should_return_the_recorded_endpoint_as_route() {
|
||||
async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
@@ -293,6 +297,7 @@ async fn usage_route_should_return_the_recorded_endpoint_as_route() {
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
http_version: Some("h2".to_owned()),
|
||||
websocket_pool: Some("reuse".to_owned()),
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: None,
|
||||
@@ -311,6 +316,8 @@ async fn usage_route_should_return_the_recorded_endpoint_as_route() {
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(31),
|
||||
image_output_tokens: Some(9),
|
||||
total_tokens: Some(2),
|
||||
cost_source: "unavailable".to_owned(),
|
||||
cost_amount: None,
|
||||
@@ -332,6 +339,8 @@ async fn usage_route_should_return_the_recorded_endpoint_as_route() {
|
||||
request_kind: None,
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: true,
|
||||
image_generation_succeeded: Some(true),
|
||||
started_at: Utc::now(),
|
||||
deadline_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
@@ -354,5 +363,22 @@ async fn usage_route_should_return_the_recorded_endpoint_as_route() {
|
||||
.expect("usage response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
|
||||
|
||||
assert_eq!(value["data"]["items"][0]["route"], "/v1/responses");
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"imageInputTokens": value["data"]["items"][0]["tokenDetails"]["imageInputTokens"],
|
||||
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
|
||||
"websocketPool": value["data"]["items"][0]["metadata"]["websocketPool"],
|
||||
"imageGenerationRequested": value["data"]["items"][0]["metadata"]["imageGenerationRequested"],
|
||||
"imageGenerationSucceeded": value["data"]["items"][0]["metadata"]["imageGenerationSucceeded"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"route": "/v1/responses",
|
||||
"imageInputTokens": 31,
|
||||
"imageOutputTokens": 9,
|
||||
"websocketPool": "reuse",
|
||||
"imageGenerationRequested": true,
|
||||
"imageGenerationSucceeded": true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,17 @@ fn decoder_should_preserve_roles_tools_reasoning_schema_and_provider_options() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_mark_image_generation_tool_intent() {
|
||||
let decoded = generate_request(json!({
|
||||
"model": "smart-code",
|
||||
"input": "draw a circuit diagram",
|
||||
"tools": [{"type": "image_generation"}]
|
||||
}));
|
||||
|
||||
assert!(generate_operation(&decoded).image_generation_requested());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_only_explicit_xai_provider_options() {
|
||||
let decoded = generate_request(json!({
|
||||
|
||||
@@ -152,7 +152,7 @@ impl Money {
|
||||
}
|
||||
}
|
||||
|
||||
/// `model_requests` 的六个公共 Token 事实。
|
||||
/// `model_requests` 的公共 Token 事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -160,6 +160,8 @@ pub struct Usage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
/// Provider/协议报告的独立事实,不从其他列相加推导。
|
||||
pub total_tokens: Option<u64>,
|
||||
}
|
||||
@@ -173,6 +175,8 @@ impl Usage {
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: None,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +198,12 @@ impl Usage {
|
||||
if newer.reasoning_tokens.is_some() {
|
||||
self.reasoning_tokens = newer.reasoning_tokens;
|
||||
}
|
||||
if newer.image_input_tokens.is_some() {
|
||||
self.image_input_tokens = newer.image_input_tokens;
|
||||
}
|
||||
if newer.image_output_tokens.is_some() {
|
||||
self.image_output_tokens = newer.image_output_tokens;
|
||||
}
|
||||
if newer.total_tokens.is_some() {
|
||||
self.total_tokens = newer.total_tokens;
|
||||
}
|
||||
@@ -223,7 +233,6 @@ impl CostSource {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CostEstimateStatus {
|
||||
Known,
|
||||
Partial,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -232,7 +241,6 @@ impl CostEstimateStatus {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Known => "known",
|
||||
Self::Partial => "partial",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -486,28 +494,6 @@ impl CostEstimate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建有金额的 known/partial 结果。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `Unavailable` 不能携带金额,`Unknown` 不能伪装成可信金额。
|
||||
pub fn priced(
|
||||
status: CostEstimateStatus,
|
||||
source: CostSource,
|
||||
total: Money,
|
||||
) -> Result<Self, AccountingError> {
|
||||
if source == CostSource::Unavailable || status == CostEstimateStatus::Unknown {
|
||||
return Err(AccountingError::InvalidCostEstimate {
|
||||
status: status.as_str(),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
status,
|
||||
source,
|
||||
total: Some(total),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn status(&self) -> CostEstimateStatus {
|
||||
self.status
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use std::fmt;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::engine::credential::ProviderAccountId;
|
||||
use crate::error::{IdentifierError, SafeUpstreamValue, validate_text};
|
||||
@@ -35,13 +34,6 @@ impl PreviousResponseId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 声明的 native handle 复用方式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NativeContinuationReuse {
|
||||
Reusable,
|
||||
SingleUse,
|
||||
}
|
||||
|
||||
/// Provider 原生 response handle 的续接范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NativeContinuationScope {
|
||||
@@ -64,7 +56,6 @@ pub struct NativeContinuationPin {
|
||||
provider: ProviderKind,
|
||||
instance: ProviderInstanceId,
|
||||
account: ProviderAccountId,
|
||||
reuse: NativeContinuationReuse,
|
||||
scope: NativeContinuationScope,
|
||||
}
|
||||
|
||||
@@ -76,7 +67,6 @@ impl NativeContinuationPin {
|
||||
provider: ProviderKind,
|
||||
instance: ProviderInstanceId,
|
||||
account: ProviderAccountId,
|
||||
reuse: NativeContinuationReuse,
|
||||
) -> Self {
|
||||
Self {
|
||||
previous_response_id,
|
||||
@@ -84,7 +74,6 @@ impl NativeContinuationPin {
|
||||
provider,
|
||||
instance,
|
||||
account,
|
||||
reuse,
|
||||
scope: NativeContinuationScope::ConnectionLocal,
|
||||
}
|
||||
}
|
||||
@@ -122,11 +111,6 @@ impl NativeContinuationPin {
|
||||
&self.account
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reuse(&self) -> NativeContinuationReuse {
|
||||
self.reuse
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn scope(&self) -> NativeContinuationScope {
|
||||
self.scope
|
||||
@@ -153,7 +137,6 @@ impl fmt::Debug for NativeContinuationPin {
|
||||
.field("provider", &self.provider)
|
||||
.field("instance", &self.instance)
|
||||
.field("account", &self.account)
|
||||
.field("reuse", &self.reuse)
|
||||
.field("scope", &self.scope)
|
||||
.finish()
|
||||
}
|
||||
@@ -199,82 +182,6 @@ impl fmt::Debug for ContinuationBinding {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request-local 的 single-use claim 状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NativeClaimState {
|
||||
Available,
|
||||
Claimed,
|
||||
Consumed,
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// 不落库的 native claim 状态机。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NativeClaim {
|
||||
reuse: NativeContinuationReuse,
|
||||
state: NativeClaimState,
|
||||
}
|
||||
|
||||
impl NativeClaim {
|
||||
#[must_use]
|
||||
pub const fn new(reuse: NativeContinuationReuse) -> Self {
|
||||
Self {
|
||||
reuse,
|
||||
state: NativeClaimState::Available,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn state(self) -> NativeClaimState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// 在发送前领取 handle。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Single-use handle 已经被领取或终结时返回错误。
|
||||
pub fn claim(&mut self) -> Result<(), ContinuationError> {
|
||||
if self.reuse == NativeContinuationReuse::Reusable {
|
||||
return Ok(());
|
||||
}
|
||||
if self.state != NativeClaimState::Available {
|
||||
return Err(ContinuationError::AlreadyClaimed);
|
||||
}
|
||||
self.state = NativeClaimState::Claimed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 明确未发送时释放 single-use claim。
|
||||
pub fn release_not_sent(&mut self) {
|
||||
if self.reuse == NativeContinuationReuse::SingleUse
|
||||
&& self.state == NativeClaimState::Claimed
|
||||
{
|
||||
self.state = NativeClaimState::Available;
|
||||
}
|
||||
}
|
||||
|
||||
/// 上游明确接收后消费 handle。
|
||||
pub fn consume(&mut self) {
|
||||
if self.reuse == NativeContinuationReuse::SingleUse {
|
||||
self.state = NativeClaimState::Consumed;
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送结果不确定时禁止再次使用 handle。
|
||||
pub fn mark_ambiguous(&mut self) {
|
||||
if self.reuse == NativeContinuationReuse::SingleUse {
|
||||
self.state = NativeClaimState::Ambiguous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum ContinuationError {
|
||||
#[error("native continuation handle is already claimed or terminal")]
|
||||
AlreadyClaimed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("native continuation store is unavailable")]
|
||||
pub struct NativeContinuationStoreError;
|
||||
|
||||
@@ -68,6 +68,7 @@ where
|
||||
.map(ProviderAccountStateOwner::from_continuation);
|
||||
let continuation_attempt =
|
||||
initial_continuation_attempt(&operation, &plan, continuation.as_ref());
|
||||
let image_generation_requested = operation.image_generation_requested();
|
||||
let commit_policy = if continuation_attempt == ContinuationAttempt::None {
|
||||
StreamCommitPolicy::FirstCanonicalEvent
|
||||
} else {
|
||||
@@ -100,6 +101,7 @@ where
|
||||
upstream_complete: false,
|
||||
finalized: false,
|
||||
usage: Usage::new(),
|
||||
image_generation_requested,
|
||||
cost: CostEstimate::unavailable(),
|
||||
timings: ModelRequestTimings::default(),
|
||||
gateway_response_id,
|
||||
@@ -177,6 +179,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
upstream_complete: bool,
|
||||
finalized: bool,
|
||||
usage: Usage,
|
||||
image_generation_requested: bool,
|
||||
cost: CostEstimate,
|
||||
timings: ModelRequestTimings,
|
||||
gateway_response_id: String,
|
||||
@@ -955,7 +958,8 @@ where
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::status_code)
|
||||
.or(Some(200));
|
||||
let (upstream_transport, http_version) = self.current_transport_observation();
|
||||
let (upstream_transport, http_version, websocket_pool) =
|
||||
self.current_transport_observation();
|
||||
self.engine
|
||||
.store()
|
||||
.finalize_model_request(ModelRequestFinalization {
|
||||
@@ -971,10 +975,12 @@ where
|
||||
upstream_response_id: self.upstream_response_id.clone(),
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
retry_after_ms: None,
|
||||
usage: self.usage.clone(),
|
||||
image_generation_succeeded: self.image_generation_succeeded(),
|
||||
cost: self.cost.clone(),
|
||||
timings: self.timings.clone(),
|
||||
completed_at,
|
||||
@@ -1094,7 +1100,8 @@ where
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::status_code);
|
||||
let (upstream_transport, http_version) = self.current_transport_observation();
|
||||
let (upstream_transport, http_version, websocket_pool) =
|
||||
self.current_transport_observation();
|
||||
self.engine
|
||||
.store()
|
||||
.finalize_model_request(ModelRequestFinalization {
|
||||
@@ -1112,10 +1119,12 @@ where
|
||||
.or_else(|| self.upstream_response_id.clone()),
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
error: Some(finalization.error),
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
retry_after_ms: finalization.retry_after_ms,
|
||||
usage: self.usage.clone(),
|
||||
image_generation_succeeded: self.image_generation_succeeded(),
|
||||
cost: self.cost.clone(),
|
||||
timings: self.timings.clone(),
|
||||
completed_at,
|
||||
@@ -1125,21 +1134,29 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_transport_observation(&self) -> (Option<String>, Option<String>) {
|
||||
fn current_transport_observation(&self) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let Some(current) = self.current.as_ref() else {
|
||||
return (None, None);
|
||||
return (None, None, None);
|
||||
};
|
||||
let Some(observation) = current.response_observation.as_ref() else {
|
||||
return (None, None);
|
||||
return (None, None, None);
|
||||
};
|
||||
(
|
||||
Some(observation.transport().as_str().to_owned()),
|
||||
observation
|
||||
.http_version()
|
||||
.map(|version| version.as_str().to_owned()),
|
||||
observation
|
||||
.websocket_pool()
|
||||
.map(|kind| kind.as_str().to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
fn image_generation_succeeded(&self) -> Option<bool> {
|
||||
self.image_generation_requested
|
||||
.then(|| self.usage.image_output_tokens.unwrap_or_default() > 0)
|
||||
}
|
||||
|
||||
fn current_send_state(&self) -> UpstreamSendState {
|
||||
if self.attempts == 0 {
|
||||
UpstreamSendState::NotSent
|
||||
|
||||
@@ -341,6 +341,7 @@ impl DefaultExecutionService {
|
||||
request_kind: observation.request_kind,
|
||||
subagent_kind: observation.subagent_kind,
|
||||
compact: observation.compact,
|
||||
image_generation_requested: request.operation.image_generation_requested(),
|
||||
started_at,
|
||||
deadline_at,
|
||||
};
|
||||
@@ -488,6 +489,7 @@ impl DefaultExecutionService {
|
||||
request_kind: Some("account_connection_test".to_owned()),
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: false,
|
||||
started_at,
|
||||
deadline_at,
|
||||
};
|
||||
|
||||
@@ -523,6 +523,7 @@ pub struct NewModelRequest {
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: bool,
|
||||
pub image_generation_requested: bool,
|
||||
pub started_at: SystemTime,
|
||||
pub deadline_at: SystemTime,
|
||||
}
|
||||
@@ -587,10 +588,12 @@ pub struct ModelRequestFinalization {
|
||||
pub upstream_response_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub error: Option<GatewayError>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub usage: Usage,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub cost: CostEstimate,
|
||||
pub timings: ModelRequestTimings,
|
||||
pub completed_at: SystemTime,
|
||||
|
||||
@@ -115,12 +115,6 @@ pub enum AccountingError {
|
||||
/// 货币代码无效。
|
||||
#[error("currency must be a three-letter uppercase ASCII code")]
|
||||
InvalidCurrency,
|
||||
/// 价格估算状态与金额字段不一致。
|
||||
#[error("cost estimate fields do not match status `{status}`")]
|
||||
InvalidCostEstimate {
|
||||
/// 估算状态。
|
||||
status: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// 跨 Provider 稳定的上游失败分类。
|
||||
|
||||
@@ -45,16 +45,6 @@ impl ResponseMeta {
|
||||
&self.response_id
|
||||
}
|
||||
|
||||
/// 以客户端协议层冻结的网关 response ID 替换 Provider 临时 ID。
|
||||
///
|
||||
/// Provider ID 只用于当前 attempt 内校验事件关联;跨请求 continuation 必须
|
||||
/// 使用网关拥有且可按调用方隔离解析的 ID。
|
||||
#[must_use]
|
||||
pub fn with_gateway_response_id(mut self, response_id: impl Into<String>) -> Self {
|
||||
self.response_id = response_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回对客户端公开的模型名。
|
||||
#[must_use]
|
||||
pub fn model(&self) -> &str {
|
||||
@@ -477,6 +467,7 @@ impl fmt::Debug for ProviderResponseHeader {
|
||||
pub struct ProviderResponseObservation {
|
||||
transport: UpstreamTransport,
|
||||
http_version: Option<UpstreamHttpVersion>,
|
||||
websocket_pool: Option<WebSocketPoolKind>,
|
||||
status_code: Option<u16>,
|
||||
request_id: Option<SafeUpstreamValue>,
|
||||
timings: ProviderResponseTimings,
|
||||
@@ -489,6 +480,7 @@ impl ProviderResponseObservation {
|
||||
Self {
|
||||
transport,
|
||||
http_version: None,
|
||||
websocket_pool: None,
|
||||
status_code: None,
|
||||
request_id: None,
|
||||
timings: ProviderResponseTimings::default(),
|
||||
@@ -502,6 +494,12 @@ impl ProviderResponseObservation {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_websocket_pool(mut self, kind: WebSocketPoolKind) -> Self {
|
||||
self.websocket_pool = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_status_code(mut self, status_code: u16) -> Self {
|
||||
self.status_code = (100..=599).contains(&status_code).then_some(status_code);
|
||||
@@ -536,6 +534,11 @@ impl ProviderResponseObservation {
|
||||
self.http_version
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn websocket_pool(&self) -> Option<WebSocketPoolKind> {
|
||||
self.websocket_pool
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn status_code(&self) -> Option<u16> {
|
||||
self.status_code
|
||||
@@ -557,6 +560,23 @@ impl ProviderResponseObservation {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket 请求使用新连接或复用池中连接的事实。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum WebSocketPoolKind {
|
||||
New,
|
||||
Reuse,
|
||||
}
|
||||
|
||||
impl WebSocketPoolKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::New => "new",
|
||||
Self::Reuse => "reuse",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有 Provider 都必须输出的稳定事件集合。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
@@ -640,13 +660,6 @@ impl ProviderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 附着只由同协议客户端连接保存、并在下一轮原样交还 Provider 的状态。
|
||||
#[must_use]
|
||||
pub fn with_session_update(mut self, state: ProviderSessionState) -> Self {
|
||||
self.session_update = Some(Box::new(state));
|
||||
self
|
||||
}
|
||||
|
||||
/// 把本事件标记为 Provider 连接内状态的提交边界。
|
||||
pub fn attach_session_update(&mut self, state: ProviderSessionState) {
|
||||
self.session_update = Some(Box::new(state));
|
||||
@@ -732,15 +745,6 @@ impl From<GatewayEvent> for ProviderEvent {
|
||||
}
|
||||
|
||||
impl GatewayEvent {
|
||||
/// Canonical event 均为客户端 encoder 可交付事件。
|
||||
///
|
||||
/// Attempt Coordinator 会把首个事件标记为 downstream commit 候选,
|
||||
/// adapter 可以先编码,但在 commit 持久化成功前不得把结果交给客户端。
|
||||
#[must_use]
|
||||
pub const fn is_downstream_deliverable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 返回该 fact 是否足以冻结下游 commit barrier。
|
||||
///
|
||||
/// 生命周期、结构与结算 facts 可以在换号前安全丢弃;首个可见增量或
|
||||
|
||||
@@ -656,7 +656,7 @@ pub struct GenerateRequest {
|
||||
continuation: Option<ContinuationMode>,
|
||||
max_output_tokens: Option<u64>,
|
||||
estimated_context_tokens: u64,
|
||||
retry_safety: RetrySafety,
|
||||
image_generation_requested: bool,
|
||||
response_persistence: ResponsePersistence,
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ impl GenerateRequest {
|
||||
continuation: None,
|
||||
max_output_tokens: None,
|
||||
estimated_context_tokens: 0,
|
||||
retry_safety: RetrySafety::NonIdempotent,
|
||||
image_generation_requested: false,
|
||||
response_persistence: ResponsePersistence::Store,
|
||||
}
|
||||
}
|
||||
@@ -767,10 +767,10 @@ impl GenerateRequest {
|
||||
self
|
||||
}
|
||||
|
||||
/// 显式声明重放安全性。
|
||||
/// 记录协议 adapter 已识别的图片生成工具意图。
|
||||
#[must_use]
|
||||
pub const fn with_retry_safety(mut self, retry_safety: RetrySafety) -> Self {
|
||||
self.retry_safety = retry_safety;
|
||||
pub const fn with_image_generation_requested(mut self, requested: bool) -> Self {
|
||||
self.image_generation_requested = requested;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -855,10 +855,10 @@ impl GenerateRequest {
|
||||
self.estimated_context_tokens
|
||||
}
|
||||
|
||||
/// 返回显式重放安全性。
|
||||
/// 返回客户端是否请求了图片生成工具。
|
||||
#[must_use]
|
||||
pub const fn retry_safety(&self) -> RetrySafety {
|
||||
self.retry_safety
|
||||
pub const fn image_generation_requested(&self) -> bool {
|
||||
self.image_generation_requested
|
||||
}
|
||||
|
||||
/// 返回客户端的响应持久化意图。
|
||||
@@ -939,7 +939,10 @@ impl fmt::Debug for GenerateRequest {
|
||||
&self.payload.prompt_cache_key.as_ref().map(|_| "<present>"),
|
||||
)
|
||||
.field("estimated_context_tokens", &self.estimated_context_tokens)
|
||||
.field("retry_safety", &self.retry_safety)
|
||||
.field(
|
||||
"image_generation_requested",
|
||||
&self.image_generation_requested,
|
||||
)
|
||||
.field("response_persistence", &self.response_persistence)
|
||||
.field("provider_options", &self.payload.provider_options)
|
||||
.field(
|
||||
@@ -1104,7 +1107,6 @@ impl fmt::Debug for RerankRequest {
|
||||
pub struct ImageRequest {
|
||||
prompt: String,
|
||||
count: u32,
|
||||
retry_safety: RetrySafety,
|
||||
}
|
||||
|
||||
impl ImageRequest {
|
||||
@@ -1118,11 +1120,7 @@ impl ImageRequest {
|
||||
if prompt.is_empty() {
|
||||
return Err(OperationError::EmptyField { field: "prompt" });
|
||||
}
|
||||
Ok(Self {
|
||||
prompt,
|
||||
count: 1,
|
||||
retry_safety: RetrySafety::NonIdempotent,
|
||||
})
|
||||
Ok(Self { prompt, count: 1 })
|
||||
}
|
||||
|
||||
/// 设置图像数量。
|
||||
@@ -1139,7 +1137,6 @@ impl fmt::Debug for ImageRequest {
|
||||
.debug_struct("ImageRequest")
|
||||
.field("prompt", &"<not included in Debug>")
|
||||
.field("count", &self.count)
|
||||
.field("retry_safety", &self.retry_safety)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1149,7 +1146,6 @@ impl fmt::Debug for ImageRequest {
|
||||
pub struct SpeechRequest {
|
||||
input: String,
|
||||
voice: String,
|
||||
retry_safety: RetrySafety,
|
||||
}
|
||||
|
||||
impl SpeechRequest {
|
||||
@@ -1167,11 +1163,7 @@ impl SpeechRequest {
|
||||
if voice.is_empty() {
|
||||
return Err(OperationError::EmptyField { field: "voice" });
|
||||
}
|
||||
Ok(Self {
|
||||
input,
|
||||
voice,
|
||||
retry_safety: RetrySafety::NonIdempotent,
|
||||
})
|
||||
Ok(Self { input, voice })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,7 +1173,6 @@ impl fmt::Debug for SpeechRequest {
|
||||
.debug_struct("SpeechRequest")
|
||||
.field("input", &"<not included in Debug>")
|
||||
.field("voice", &self.voice)
|
||||
.field("retry_safety", &self.retry_safety)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1247,11 +1238,24 @@ impl Operation {
|
||||
#[must_use]
|
||||
pub const fn retry_safety(&self) -> RetrySafety {
|
||||
match self {
|
||||
Self::Generate(request) => request.retry_safety,
|
||||
Self::CompactConversation(request) => request.generation.retry_safety,
|
||||
Self::Embed(_) | Self::Rerank(_) => RetrySafety::Idempotent,
|
||||
Self::GenerateImage(request) => request.retry_safety,
|
||||
Self::Speech(request) => request.retry_safety,
|
||||
Self::Generate(_)
|
||||
| Self::CompactConversation(_)
|
||||
| Self::GenerateImage(_)
|
||||
| Self::Speech(_) => RetrySafety::NonIdempotent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回该 operation 是否代表一次图片生成请求。
|
||||
#[must_use]
|
||||
pub const fn image_generation_requested(&self) -> bool {
|
||||
match self {
|
||||
Self::Generate(request) => request.image_generation_requested(),
|
||||
Self::CompactConversation(_)
|
||||
| Self::Embed(_)
|
||||
| Self::Rerank(_)
|
||||
| Self::GenerateImage(_)
|
||||
| Self::Speech(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -529,11 +529,6 @@ impl RuntimeSnapshot {
|
||||
self.client_policies.values()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_policy(&self, key_id: &ClientApiKeyId) -> Option<&ClientPolicy> {
|
||||
self.client_policies.get(key_id)
|
||||
}
|
||||
|
||||
pub fn plan(
|
||||
&self,
|
||||
public_model: &PublicModelId,
|
||||
|
||||
@@ -18,19 +18,17 @@ pub enum WorkerKind {
|
||||
QuotaCatalogHealth,
|
||||
RuntimeSnapshotReconciliation,
|
||||
RuntimeChangeSubscription,
|
||||
NativeClaimRecovery,
|
||||
StaleModelRequestRecovery,
|
||||
Retention,
|
||||
OpsFlush,
|
||||
}
|
||||
|
||||
impl WorkerKind {
|
||||
pub const ALL: [Self; 8] = [
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::OAuthRefresh,
|
||||
Self::QuotaCatalogHealth,
|
||||
Self::RuntimeSnapshotReconciliation,
|
||||
Self::RuntimeChangeSubscription,
|
||||
Self::NativeClaimRecovery,
|
||||
Self::StaleModelRequestRecovery,
|
||||
Self::Retention,
|
||||
Self::OpsFlush,
|
||||
@@ -43,7 +41,6 @@ impl WorkerKind {
|
||||
Self::QuotaCatalogHealth => "quota_catalog_health",
|
||||
Self::RuntimeSnapshotReconciliation => "runtime_snapshot_reconciliation",
|
||||
Self::RuntimeChangeSubscription => "runtime_change_subscription",
|
||||
Self::NativeClaimRecovery => "native_claim_recovery",
|
||||
Self::StaleModelRequestRecovery => "stale_model_request_recovery",
|
||||
Self::Retention => "retention",
|
||||
Self::OpsFlush => "ops_flush",
|
||||
@@ -402,10 +399,9 @@ impl WorkerRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Final DB 明确没有相应持久化状态时允许的两种禁用原因。
|
||||
/// Final DB 明确没有相应持久化状态时允许的禁用原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum WorkerDisabledReason {
|
||||
NoPersistentNativeClaimState,
|
||||
NoBufferedOpsEvents,
|
||||
}
|
||||
|
||||
@@ -413,7 +409,6 @@ impl WorkerDisabledReason {
|
||||
#[must_use]
|
||||
pub const fn kind(self) -> WorkerKind {
|
||||
match self {
|
||||
Self::NoPersistentNativeClaimState => WorkerKind::NativeClaimRecovery,
|
||||
Self::NoBufferedOpsEvents => WorkerKind::OpsFlush,
|
||||
}
|
||||
}
|
||||
@@ -421,9 +416,6 @@ impl WorkerDisabledReason {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoPersistentNativeClaimState => {
|
||||
"native continuation has no persistent claim state"
|
||||
}
|
||||
Self::NoBufferedOpsEvents => "ops events have no flush buffer",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,33 +77,6 @@ fn unknown_cost_should_not_fabricate_zero() {
|
||||
assert_eq!(CostEstimate::unavailable().total(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_reported_cost_should_preserve_exact_total() {
|
||||
let total = Money::new(
|
||||
Decimal::from_str("0.00125").expect("valid amount"),
|
||||
CurrencyCode::new("USD").expect("valid currency"),
|
||||
);
|
||||
let estimate = CostEstimate::priced(
|
||||
CostEstimateStatus::Known,
|
||||
CostSource::ProviderReported,
|
||||
total,
|
||||
)
|
||||
.expect("reported total is valid");
|
||||
|
||||
assert_eq!(estimate.total(), Some(total));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_source_should_reject_amount() {
|
||||
let total = Money::new(
|
||||
Decimal::ZERO,
|
||||
CurrencyCode::new("USD").expect("valid currency"),
|
||||
);
|
||||
assert!(
|
||||
CostEstimate::priced(CostEstimateStatus::Known, CostSource::Unavailable, total).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_total_should_remain_independent() {
|
||||
let usage = Usage {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use gateway_core::engine::continuation::{
|
||||
ContinuationBinding, NativeClaim, NativeClaimState, NativeContinuationPin,
|
||||
NativeContinuationReuse, PreviousResponseId,
|
||||
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use gateway_core::error::SafeUpstreamValue;
|
||||
@@ -13,7 +12,6 @@ fn pin() -> NativeContinuationPin {
|
||||
ProviderKind::new("openai").expect("valid provider"),
|
||||
ProviderInstanceId::new("inst_openai").expect("valid instance"),
|
||||
ProviderAccountId::new("acct_codex").expect("valid account"),
|
||||
NativeContinuationReuse::SingleUse,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,29 +39,3 @@ fn native_pin_should_reject_different_account() {
|
||||
&ProviderAccountId::new("acct_other").expect("valid account"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_use_claim_should_not_be_claimed_twice() {
|
||||
let mut claim = NativeClaim::new(NativeContinuationReuse::SingleUse);
|
||||
claim.claim().expect("first claim succeeds");
|
||||
|
||||
assert!(claim.claim().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proven_not_sent_should_release_single_use_claim() {
|
||||
let mut claim = NativeClaim::new(NativeContinuationReuse::SingleUse);
|
||||
claim.claim().expect("claim succeeds");
|
||||
claim.release_not_sent();
|
||||
|
||||
assert_eq!(claim.state(), NativeClaimState::Available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_send_should_make_single_use_claim_terminal() {
|
||||
let mut claim = NativeClaim::new(NativeContinuationReuse::SingleUse);
|
||||
claim.claim().expect("claim succeeds");
|
||||
claim.mark_ambiguous();
|
||||
|
||||
assert_eq!(claim.state(), NativeClaimState::Ambiguous);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use futures::executor::block_on;
|
||||
|
||||
use gateway_core::accounting::{CalculatedCost, CostSource, ProviderReportedCost, Usage};
|
||||
use gateway_core::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, NativeContinuationReuse, PreviousResponseId,
|
||||
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, CredentialRevision, ProviderAccountId, RotationStrategy,
|
||||
@@ -30,10 +30,10 @@ use gateway_core::error::{
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseObservation,
|
||||
ProviderResponseTimings, ResponseMeta, UpstreamHttpVersion,
|
||||
ProviderResponseTimings, ResponseMeta, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::operation::{
|
||||
ContentPart, GenerateRequest, Message, MessageRole, Operation, OperationKind,
|
||||
ContentPart, EmbedRequest, GenerateRequest, Message, MessageRole, Operation,
|
||||
ProviderSessionState, RetrySafety,
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
@@ -52,6 +52,9 @@ struct FinalState {
|
||||
committed: bool,
|
||||
client_status_code: Option<u16>,
|
||||
total_tokens: Option<u64>,
|
||||
image_input_tokens: Option<u64>,
|
||||
image_output_tokens: Option<u64>,
|
||||
image_generation_succeeded: Option<bool>,
|
||||
provider_error_code: Option<String>,
|
||||
retry_after_ms: Option<u64>,
|
||||
latency_ms: Option<u64>,
|
||||
@@ -59,6 +62,7 @@ struct FinalState {
|
||||
upstream_response_id: Option<String>,
|
||||
upstream_transport: Option<String>,
|
||||
http_version: Option<String>,
|
||||
websocket_pool: Option<String>,
|
||||
upstream_request_id: Option<String>,
|
||||
upstream_status_code: Option<u16>,
|
||||
transport_decision_wait_ms: Option<u64>,
|
||||
@@ -172,6 +176,9 @@ impl ExecutionStore for FakeStore {
|
||||
committed: finalization.downstream_committed_at.is_some(),
|
||||
client_status_code: finalization.client_status_code,
|
||||
total_tokens: finalization.usage.total_tokens,
|
||||
image_input_tokens: finalization.usage.image_input_tokens,
|
||||
image_output_tokens: finalization.usage.image_output_tokens,
|
||||
image_generation_succeeded: finalization.image_generation_succeeded,
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
retry_after_ms: finalization.retry_after_ms,
|
||||
latency_ms: finalization.timings.latency_ms,
|
||||
@@ -179,6 +186,7 @@ impl ExecutionStore for FakeStore {
|
||||
upstream_response_id: finalization.upstream_response_id,
|
||||
upstream_transport: finalization.upstream_transport,
|
||||
http_version: finalization.http_version,
|
||||
websocket_pool: finalization.websocket_pool,
|
||||
upstream_request_id: finalization.upstream_request_id,
|
||||
upstream_status_code: finalization.upstream_status_code,
|
||||
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
|
||||
@@ -304,16 +312,28 @@ impl Provider for ScriptedProvider {
|
||||
}
|
||||
|
||||
fn operation(retry_safety: RetrySafety) -> Operation {
|
||||
if retry_safety == RetrySafety::Idempotent {
|
||||
return Operation::Embed(
|
||||
EmbedRequest::new(vec!["hello".to_owned()]).expect("embedding request"),
|
||||
);
|
||||
}
|
||||
generate_operation()
|
||||
}
|
||||
|
||||
fn generate_operation() -> Operation {
|
||||
let message = Message::new(
|
||||
MessageRole::User,
|
||||
vec![ContentPart::Text("hello".to_owned())],
|
||||
)
|
||||
.expect("message");
|
||||
Operation::Generate(
|
||||
GenerateRequest::new(vec![message])
|
||||
.expect("generate request")
|
||||
.with_retry_safety(retry_safety),
|
||||
)
|
||||
Operation::Generate(GenerateRequest::new(vec![message]).expect("generate request"))
|
||||
}
|
||||
|
||||
fn image_generate_operation() -> Operation {
|
||||
let Operation::Generate(request) = generate_operation() else {
|
||||
unreachable!("generate_operation must return Generate")
|
||||
};
|
||||
Operation::Generate(request.with_image_generation_requested(true))
|
||||
}
|
||||
|
||||
fn complete_stream(total_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
|
||||
@@ -335,14 +355,31 @@ fn complete_stream(total_tokens: Option<u64>) -> Vec<Result<GatewayEvent, Provid
|
||||
events
|
||||
}
|
||||
|
||||
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
|
||||
let mut usage = Usage::new();
|
||||
usage.input_tokens = Some(12);
|
||||
usage.output_tokens = Some(5);
|
||||
usage.image_input_tokens = Some(31);
|
||||
usage.image_output_tokens = image_output_tokens;
|
||||
usage.total_tokens = Some(17);
|
||||
vec![
|
||||
Ok(GatewayEvent::Started(ResponseMeta::new(
|
||||
"response-image",
|
||||
"gpt-5",
|
||||
))),
|
||||
Ok(GatewayEvent::Usage(usage)),
|
||||
Ok(GatewayEvent::Completed(ResponseMeta::new(
|
||||
"response-image",
|
||||
"gpt-5",
|
||||
))),
|
||||
]
|
||||
}
|
||||
|
||||
fn plan(operation: &Operation, instance_count: u32) -> RoutingPlan {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let public_model = PublicModelId::new("gpt-5").expect("public model");
|
||||
let capabilities = ModelCapabilities::new(
|
||||
BTreeSet::from([OperationKind::Generate]),
|
||||
128_000,
|
||||
Some(32_000),
|
||||
);
|
||||
let capabilities =
|
||||
ModelCapabilities::new(BTreeSet::from([operation.kind()]), 128_000, Some(32_000));
|
||||
let mut instances = Vec::new();
|
||||
let mut provider_models = Vec::new();
|
||||
for index in 1..=instance_count {
|
||||
@@ -384,7 +421,7 @@ fn plan(operation: &Operation, instance_count: u32) -> RoutingPlan {
|
||||
.expect("routing plan")
|
||||
}
|
||||
|
||||
fn model_request(deadline: SystemTime) -> NewModelRequest {
|
||||
fn model_request(operation: &Operation, deadline: SystemTime) -> NewModelRequest {
|
||||
let client_key = ClientApiKeyId::new("key_client_1").expect("client key id");
|
||||
NewModelRequest {
|
||||
id: ModelRequestId::new("req_core_1").expect("request id"),
|
||||
@@ -392,7 +429,7 @@ fn model_request(deadline: SystemTime) -> NewModelRequest {
|
||||
client_api_key_ref: client_key,
|
||||
config_revision: ConfigRevision::new(1).expect("config revision"),
|
||||
protocol: "openai".to_owned(),
|
||||
operation: OperationKind::Generate,
|
||||
operation: operation.kind(),
|
||||
endpoint: "responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model: PublicModelId::new("gpt-5").expect("model"),
|
||||
@@ -404,6 +441,7 @@ fn model_request(deadline: SystemTime) -> NewModelRequest {
|
||||
request_kind: Some("responses".to_owned()),
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: operation.image_generation_requested(),
|
||||
started_at: SystemTime::now(),
|
||||
deadline_at: deadline,
|
||||
}
|
||||
@@ -443,7 +481,7 @@ fn terminal_non_idempotent_failure(
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -469,7 +507,7 @@ fn success_updates_one_model_request_and_persists_usage() {
|
||||
}]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -521,6 +559,78 @@ fn success_updates_one_model_request_and_persists_usage() {
|
||||
);
|
||||
}
|
||||
|
||||
fn finalized_image_request(image_output_tokens: Option<u64>) -> FinalState {
|
||||
let operation = image_generate_operation();
|
||||
let route_plan = plan(&operation, 1);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_image",
|
||||
items: image_stream(image_output_tokens),
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start image execution");
|
||||
block_on(session.collect_uncommitted()).expect("collect image response");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit image response");
|
||||
|
||||
store.state.lock().expect("store lock").finalizations[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_request_with_output_should_persist_success_and_image_tokens() {
|
||||
let finalization = finalized_image_request(Some(9));
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
finalization.image_generation_succeeded,
|
||||
finalization.image_input_tokens,
|
||||
finalization.image_output_tokens,
|
||||
),
|
||||
(Some(true), Some(31), Some(9))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_request_without_output_should_persist_failure() {
|
||||
let finalization = finalized_image_request(None);
|
||||
|
||||
assert_eq!(finalization.image_generation_succeeded, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_image_request_should_persist_failure() {
|
||||
let operation = image_generate_operation();
|
||||
let route_plan = plan(&operation, 1);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_image",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Protocol,
|
||||
UpstreamSendState::Sent,
|
||||
))],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start image execution");
|
||||
block_on(session.collect_uncommitted()).expect_err("image request should fail");
|
||||
let state = store.state.lock().expect("store lock");
|
||||
|
||||
assert_eq!(
|
||||
state.finalizations[0].image_generation_succeeded,
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_observation_is_persisted_but_never_delivered() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
@@ -529,6 +639,7 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
UpstreamTransport::new("http_sse").expect("actual transport"),
|
||||
)
|
||||
.with_http_version(UpstreamHttpVersion::Http2)
|
||||
.with_websocket_pool(WebSocketPoolKind::New)
|
||||
.with_status_code(200)
|
||||
.with_request_id(SafeUpstreamValue::new("upstream-observed").expect("request id"))
|
||||
.with_timings(ProviderResponseTimings {
|
||||
@@ -548,7 +659,7 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
items,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -565,6 +676,7 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
let finalization = &state.finalizations[0];
|
||||
assert_eq!(finalization.upstream_transport.as_deref(), Some("http_sse"));
|
||||
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
|
||||
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
|
||||
assert_eq!(
|
||||
finalization.upstream_request_id.as_deref(),
|
||||
Some("upstream-observed")
|
||||
@@ -603,7 +715,7 @@ fn unknown_wire_event_before_response_identity_is_discarded_with_retried_attempt
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -667,7 +779,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -712,7 +824,7 @@ fn websocket_success_keeps_client_http_status_absent() {
|
||||
items: complete_stream(None),
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -731,7 +843,7 @@ fn websocket_success_keeps_client_http_status_absent() {
|
||||
|
||||
#[test]
|
||||
fn authenticated_native_continuation_reaches_every_attempt_context() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation, 1);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_one",
|
||||
@@ -743,11 +855,10 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
ProviderInstanceId::new("inst_openai_1").expect("instance"),
|
||||
ProviderAccountId::new("acct_one").expect("account"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -791,7 +902,7 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
|
||||
|
||||
#[test]
|
||||
fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
let Operation::Generate(generate) = operation(RetrySafety::Idempotent) else {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
let mut payload = Map::new();
|
||||
@@ -834,11 +945,10 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
ProviderInstanceId::new("inst_openai_1").expect("instance"),
|
||||
ProviderAccountId::new("acct_one").expect("account"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -880,7 +990,7 @@ fn required_account_reaches_provider_and_matching_metadata_succeeds() {
|
||||
}]);
|
||||
let required = ProviderAccountId::new("acct_required").expect("account id");
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
Some(required.clone()),
|
||||
@@ -911,7 +1021,7 @@ fn provider_metadata_for_another_account_fails_closed() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
Some(ProviderAccountId::new("acct_required").expect("account id")),
|
||||
@@ -949,7 +1059,7 @@ fn required_account_disables_instance_fallback_before_stream() {
|
||||
]);
|
||||
let required = ProviderAccountId::new("acct_required").expect("account id");
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
Some(required.clone()),
|
||||
@@ -988,7 +1098,7 @@ fn local_selection_failure_falls_back_without_instance_failure_observation() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1034,7 +1144,7 @@ fn required_account_disables_account_retry_after_stream_creation() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
Some(ProviderAccountId::new("acct_required").expect("account id")),
|
||||
@@ -1089,7 +1199,7 @@ fn latest_provider_reported_cost_is_persisted_as_known_usd_total() {
|
||||
items: events,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1129,7 +1239,7 @@ fn calculated_cost_is_persisted_when_provider_does_not_report_cost() {
|
||||
items: events,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1173,7 +1283,7 @@ fn provider_reported_cost_should_not_be_replaced_by_calculated_cost() {
|
||||
items: events,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1224,7 +1334,7 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1264,7 +1374,7 @@ fn pre_commit_failure_excludes_account_and_retries_same_target() {
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1326,7 +1436,7 @@ fn recovered_credential_retries_the_same_account_exactly_once() {
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1370,7 +1480,7 @@ fn non_idempotent_explicit_429_rejection_rotates_account_before_output() {
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1454,7 +1564,7 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1481,7 +1591,6 @@ fn native_continuation_explicit_429_is_not_retried() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
ProviderInstanceId::new("inst_openai_1").expect("instance"),
|
||||
ProviderAccountId::new("acct_first").expect("account"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let (store, provider) = terminal_non_idempotent_failure(
|
||||
vec![Err(ProviderError::new(
|
||||
@@ -1539,7 +1648,7 @@ fn non_idempotent_not_sent_failure_can_fallback_target() {
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1581,7 +1690,7 @@ fn ambiguous_send_state_stops_retry() {
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1633,7 +1742,7 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
|
||||
]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1676,7 +1785,7 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
|
||||
items: complete_stream(None),
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1712,7 +1821,7 @@ fn local_unavailability_before_stream_does_not_create_attempt_or_instance_failur
|
||||
)]);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(SystemTime::now() + Duration::from_secs(30)),
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
@@ -1745,7 +1854,7 @@ fn expired_deadline_finalizes_without_calling_provider() {
|
||||
let (coordinator, store, provider) = coordinator(vec![]);
|
||||
|
||||
let error = match block_on(coordinator.start(
|
||||
model_request(SystemTime::UNIX_EPOCH),
|
||||
model_request(&operation, SystemTime::UNIX_EPOCH),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
|
||||
@@ -322,10 +322,13 @@ fn embed_and_rerank_operations_should_be_idempotent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_conversation_should_inherit_generation_retry_safety() {
|
||||
fn generate_and_compact_conversation_should_be_non_idempotent() {
|
||||
let generate = GenerateRequest::new(vec![text_message(MessageRole::User, "history")])
|
||||
.expect("compaction history is valid");
|
||||
assert_eq!(generate.retry_safety(), RetrySafety::NonIdempotent);
|
||||
assert_eq!(
|
||||
Operation::Generate(generate.clone()).retry_safety(),
|
||||
RetrySafety::NonIdempotent
|
||||
);
|
||||
let compact = Operation::CompactConversation(CompactConversationRequest::new(generate));
|
||||
|
||||
assert_eq!(compact.retry_safety(), RetrySafety::NonIdempotent);
|
||||
|
||||
@@ -4,9 +4,9 @@ use std::time::Duration;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::CancellationToken;
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
|
||||
WorkerDefinitionError, WorkerDisabledReason, WorkerFencingToken, WorkerId, WorkerKind,
|
||||
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerCycleContext, WorkerDefinitionError,
|
||||
WorkerFencingToken, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
|
||||
WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
};
|
||||
|
||||
struct NoopScheduledTask;
|
||||
@@ -116,19 +116,6 @@ fn registration_rejects_lease_ttl_that_differs_from_schedule() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_contribution_rejects_reason_for_another_kind() {
|
||||
let contribution = WorkerContribution::Disabled {
|
||||
kind: WorkerKind::OpsFlush,
|
||||
reason: WorkerDisabledReason::NoPersistentNativeClaimState,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
contribution.validate(),
|
||||
Err(WorkerDefinitionError::DisabledReasonMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_restart_policy_rejects_reverse_backoff_range() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -344,10 +344,10 @@ async fn registry_composes_multiple_owners_but_rejects_duplicate_identity() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_database_fact_reasons_can_disable_the_two_nonexistent_workers() {
|
||||
async fn only_database_fact_reasons_can_disable_nonexistent_workers() {
|
||||
let mut plan = complete_plan(Vec::new());
|
||||
plan.push(WorkerContribution::Registration(registration_value(
|
||||
WorkerKind::NativeClaimRecovery,
|
||||
WorkerKind::OpsFlush,
|
||||
"store",
|
||||
CountingTask::new(0),
|
||||
test_schedule(),
|
||||
@@ -356,9 +356,7 @@ async fn only_database_fact_reasons_can_disable_the_two_nonexistent_workers() {
|
||||
|
||||
assert!(matches!(
|
||||
supervisor().start(plan, Arc::new(FakeLeasePort::default())),
|
||||
Err(WorkerStartError::KindDisabled(
|
||||
WorkerKind::NativeClaimRecovery
|
||||
))
|
||||
Err(WorkerStartError::KindDisabled(WorkerKind::OpsFlush))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -386,15 +384,15 @@ async fn disabled_database_facts_never_execute_or_acquire_a_lease() {
|
||||
let snapshot = supervisor.health_source().snapshot();
|
||||
|
||||
assert!(snapshot.iter().any(|state| {
|
||||
state.key == WorkerHealthKey::Disabled(WorkerKind::NativeClaimRecovery)
|
||||
state.key == WorkerHealthKey::Disabled(WorkerKind::OpsFlush)
|
||||
&& state.state == WorkerRuntimeState::Disabled
|
||||
}));
|
||||
assert!(leases.requests().iter().all(|request| {
|
||||
!matches!(
|
||||
request.worker().kind(),
|
||||
WorkerKind::NativeClaimRecovery | WorkerKind::OpsFlush
|
||||
)
|
||||
}));
|
||||
assert!(
|
||||
leases
|
||||
.requests()
|
||||
.iter()
|
||||
.all(|request| { request.worker().kind() != WorkerKind::OpsFlush })
|
||||
);
|
||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
@@ -704,10 +702,6 @@ fn complete_plan(mut custom: Vec<WorkerContribution>) -> Vec<WorkerContribution>
|
||||
));
|
||||
}
|
||||
}
|
||||
custom.push(WorkerContribution::Disabled {
|
||||
kind: WorkerKind::NativeClaimRecovery,
|
||||
reason: WorkerDisabledReason::NoPersistentNativeClaimState,
|
||||
});
|
||||
custom.push(WorkerContribution::Disabled {
|
||||
kind: WorkerKind::OpsFlush,
|
||||
reason: WorkerDisabledReason::NoBufferedOpsEvents,
|
||||
@@ -733,10 +727,6 @@ fn all_active_plan(task: impl ScheduledTask + Clone + 'static) -> Vec<WorkerCont
|
||||
.into_iter()
|
||||
.map(|kind| registration(kind, kind.as_str(), task.clone(), test_schedule(), true))
|
||||
.collect::<Vec<_>>();
|
||||
plan.push(WorkerContribution::Disabled {
|
||||
kind: WorkerKind::NativeClaimRecovery,
|
||||
reason: WorkerDisabledReason::NoPersistentNativeClaimState,
|
||||
});
|
||||
plan.push(WorkerContribution::Disabled {
|
||||
kind: WorkerKind::OpsFlush,
|
||||
reason: WorkerDisabledReason::NoBufferedOpsEvents,
|
||||
|
||||
@@ -11,7 +11,5 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
|
||||
|
||||
/// OpenAI/Codex 事件语义、用量与限流字段编解码。
|
||||
pub mod events;
|
||||
/// OpenAI/Codex JSON schema 纯转换。
|
||||
pub mod schema;
|
||||
/// Server-Sent Events 帧的解析与编码。
|
||||
pub mod sse;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Codex 协议所需的 JSON schema 纯处理逻辑。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn reconvert_tuple_values(data: Value, schema: &Value) -> Value {
|
||||
reconvert_tuple_values_with_root(data, schema, schema)
|
||||
}
|
||||
|
||||
fn reconvert_tuple_values_with_root(data: Value, schema: &Value, root_schema: &Value) -> Value {
|
||||
let Value::Object(schema_object) = schema else {
|
||||
return data;
|
||||
};
|
||||
|
||||
if let Some(reference) = schema_object.get("$ref").and_then(Value::as_str) {
|
||||
let Some(resolved) = resolve_ref(reference, root_schema) else {
|
||||
return data;
|
||||
};
|
||||
return reconvert_tuple_values_with_root(data, resolved, root_schema);
|
||||
}
|
||||
|
||||
if let Some(Value::Array(prefix_items)) = schema_object.get("prefixItems") {
|
||||
let mut data_object = match data {
|
||||
Value::Object(data_object) => data_object,
|
||||
other => return other,
|
||||
};
|
||||
let values = prefix_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item_schema)| {
|
||||
let value = data_object
|
||||
.remove(&index.to_string())
|
||||
.unwrap_or(Value::Null);
|
||||
reconvert_tuple_values_with_root(value, item_schema, root_schema)
|
||||
})
|
||||
.collect();
|
||||
return Value::Array(values);
|
||||
}
|
||||
|
||||
if let Some(Value::Object(properties)) = schema_object.get("properties") {
|
||||
let mut data_object = match data {
|
||||
Value::Object(data_object) => data_object,
|
||||
other => return other,
|
||||
};
|
||||
for (key, property_schema) in properties {
|
||||
if let Some(value) = data_object.remove(key) {
|
||||
data_object.insert(
|
||||
key.clone(),
|
||||
reconvert_tuple_values_with_root(value, property_schema, root_schema),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Value::Object(data_object);
|
||||
}
|
||||
|
||||
if let Some(items_schema) = schema_object.get("items") {
|
||||
let values = match data {
|
||||
Value::Array(values) => values,
|
||||
other => return other,
|
||||
};
|
||||
return Value::Array(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| reconvert_tuple_values_with_root(value, items_schema, root_schema))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
for key in ["oneOf", "anyOf", "allOf"] {
|
||||
let Some(Value::Array(branches)) = schema_object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(branch) = branches.iter().find(|branch| has_tuple_schemas(branch)) else {
|
||||
continue;
|
||||
};
|
||||
return reconvert_tuple_values_with_root(data, branch, root_schema);
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
fn resolve_ref<'a>(reference: &str, root_schema: &'a Value) -> Option<&'a Value> {
|
||||
let path = reference
|
||||
.strip_prefix("#/$defs/")
|
||||
.map(|path| ("$defs", path))
|
||||
.or_else(|| {
|
||||
reference
|
||||
.strip_prefix("#/definitions/")
|
||||
.map(|path| ("definitions", path))
|
||||
})?;
|
||||
root_schema.get(path.0)?.get(path.1)
|
||||
}
|
||||
|
||||
fn has_tuple_schemas(value: &Value) -> bool {
|
||||
let Value::Object(object) = value else {
|
||||
return false;
|
||||
};
|
||||
if object
|
||||
.get("prefixItems")
|
||||
.is_some_and(serde_json::Value::is_array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
object_values_have_tuple_schema(object, "properties")
|
||||
|| object.get("items").is_some_and(has_tuple_schemas)
|
||||
|| schema_array_has_tuple_schema(object, "oneOf")
|
||||
|| schema_array_has_tuple_schema(object, "anyOf")
|
||||
|| schema_array_has_tuple_schema(object, "allOf")
|
||||
|| object_values_have_tuple_schema(object, "$defs")
|
||||
|| object_values_have_tuple_schema(object, "definitions")
|
||||
|| object.get("if").is_some_and(has_tuple_schemas)
|
||||
|| object.get("then").is_some_and(has_tuple_schemas)
|
||||
|| object.get("else").is_some_and(has_tuple_schemas)
|
||||
|| object.get("not").is_some_and(has_tuple_schemas)
|
||||
}
|
||||
|
||||
fn object_values_have_tuple_schema(object: &Map<String, Value>, key: &str) -> bool {
|
||||
let Some(Value::Object(values)) = object.get(key) else {
|
||||
return false;
|
||||
};
|
||||
values.values().any(has_tuple_schemas)
|
||||
}
|
||||
|
||||
fn schema_array_has_tuple_schema(object: &Map<String, Value>, key: &str) -> bool {
|
||||
let Some(Value::Array(values)) = object.get(key) else {
|
||||
return false;
|
||||
};
|
||||
values.iter().any(has_tuple_schemas)
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
mod events;
|
||||
mod schema;
|
||||
mod sse;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
use gateway_protocol::openai::schema::reconvert_tuple_values;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tuple_reconversion_should_restore_nested_arrays_without_touching_other_properties() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"point": {
|
||||
"prefixItems": [
|
||||
{"type": "number"},
|
||||
{"type": "number"}
|
||||
]
|
||||
},
|
||||
"label": {"type": "string"}
|
||||
}
|
||||
});
|
||||
let encoded = json!({"point": {"0": 12.5, "1": 9.0}, "label": "A"});
|
||||
|
||||
assert_eq!(
|
||||
reconvert_tuple_values(encoded, &schema),
|
||||
json!({"point": [12.5, 9.0], "label": "A"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_reconversion_should_resolve_local_defs_reference() {
|
||||
let schema = json!({
|
||||
"$defs": {
|
||||
"pair": {
|
||||
"prefixItems": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"entry": {"$ref": "#/$defs/pair"}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reconvert_tuple_values(json!({"entry": {"0": "age", "1": 42}}), &schema),
|
||||
json!({"entry": ["age", 42]})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_reconversion_should_recurse_through_homogeneous_arrays() {
|
||||
let schema = json!({
|
||||
"type": "array",
|
||||
"items": {
|
||||
"prefixItems": [
|
||||
{"type": "integer"},
|
||||
{"type": "integer"}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reconvert_tuple_values(json!([{"0": 1, "1": 2}, {"0": 3, "1": 4}]), &schema,),
|
||||
json!([[1, 2], [3, 4]])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_reconversion_should_select_union_branch_containing_tuple_schema() {
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"prefixItems": [{"type": "boolean"}, {"type": "string"}]}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reconvert_tuple_values(json!({"0": true, "1": "ready"}), &schema),
|
||||
json!([true, "ready"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_reconversion_should_preserve_values_when_schema_does_not_describe_a_tuple() {
|
||||
let value = json!({"0": "not", "1": "a tuple", "extra": true});
|
||||
|
||||
assert_eq!(
|
||||
reconvert_tuple_values(value.clone(), &json!({"type": "object"})),
|
||||
value
|
||||
);
|
||||
}
|
||||
@@ -346,10 +346,6 @@ fn store_worker_contributions(
|
||||
let retention_id =
|
||||
WorkerId::try_new(WorkerKind::Retention, "postgres").map_err(worker_definition_error)?;
|
||||
Ok(vec![
|
||||
WorkerContribution::Disabled {
|
||||
kind: WorkerKind::NativeClaimRecovery,
|
||||
reason: WorkerDisabledReason::NoPersistentNativeClaimState,
|
||||
},
|
||||
WorkerContribution::Registration(scheduled_worker(
|
||||
stale_id,
|
||||
Duration::from_secs(30),
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct NewModelRequest {
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: bool,
|
||||
pub image_generation_requested: bool,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub deadline_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -171,6 +172,8 @@ pub struct ModelRequestUsage {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -222,11 +225,13 @@ pub struct ModelRequestFinalization {
|
||||
pub upstream_response_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub usage: ModelRequestUsage,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub cost_source: CostSource,
|
||||
pub cost_amount: Option<DecimalAmount>,
|
||||
pub cost_currency: Option<String>,
|
||||
@@ -265,6 +270,13 @@ impl ModelRequestFinalization {
|
||||
require_nonempty(ENTITY, field, value)?;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.websocket_pool
|
||||
.as_deref()
|
||||
.is_some_and(|kind| !matches!(kind, "new" | "reuse"))
|
||||
{
|
||||
return Err(invalid("websocket pool must be new or reuse"));
|
||||
}
|
||||
self.timings.validate()
|
||||
}
|
||||
}
|
||||
@@ -333,11 +345,11 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
id, client_api_key_id, client_api_key_ref, config_revision, protocol,
|
||||
operation, endpoint, client_transport, requested_model_id,
|
||||
input_token_estimate, client_ip, user_agent, reasoning_effort,
|
||||
reasoning_preset, request_kind, subagent_kind, compact, started_at,
|
||||
deadline_at
|
||||
reasoning_preset, request_kind, subagent_kind, compact,
|
||||
image_generation_requested, started_at, deadline_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::inet, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19
|
||||
$14, $15, $16, $17, $18, $19, $20
|
||||
)",
|
||||
)
|
||||
.bind(request.id)
|
||||
@@ -360,6 +372,7 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
.bind(request.request_kind)
|
||||
.bind(request.subagent_kind)
|
||||
.bind(request.compact)
|
||||
.bind(request.image_generation_requested)
|
||||
.bind(request.started_at)
|
||||
.bind(request.deadline_at)
|
||||
.execute(&self.pool)
|
||||
@@ -486,13 +499,15 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
error_kind = $11, provider_error_code = $12, error_message = $13,
|
||||
retry_after_ms = $14, input_tokens = $15, output_tokens = $16,
|
||||
cached_tokens = $17, cache_write_tokens = $18, reasoning_tokens = $19,
|
||||
total_tokens = $20, cost_source = $21, cost_amount = $22::numeric,
|
||||
cost_currency = $23, transport_decision_wait_ms = $24, connect_ms = $25,
|
||||
headers_ms = $26, first_event_ms = $27, first_reasoning_ms = $28,
|
||||
first_text_ms = $29, first_token_ms = $30, provider_processing_ms = $31,
|
||||
latency_ms = $32, completed_at = $33,
|
||||
upstream_transport = coalesce($34, upstream_transport),
|
||||
http_version = coalesce($35, http_version)
|
||||
image_input_tokens = $20, image_output_tokens = $21, total_tokens = $22,
|
||||
image_generation_succeeded = $23, cost_source = $24,
|
||||
cost_amount = $25::numeric, cost_currency = $26,
|
||||
transport_decision_wait_ms = $27, connect_ms = $28,
|
||||
headers_ms = $29, first_event_ms = $30, first_reasoning_ms = $31,
|
||||
first_text_ms = $32, first_token_ms = $33, provider_processing_ms = $34,
|
||||
latency_ms = $35, completed_at = $36,
|
||||
upstream_transport = coalesce($37, upstream_transport),
|
||||
http_version = coalesce($38, http_version), websocket_pool = $39
|
||||
where id = $1 and outcome = 'running'",
|
||||
)
|
||||
.bind(&finalization.model_request_id)
|
||||
@@ -532,10 +547,19 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
finalization.usage.reasoning_tokens,
|
||||
"reasoning_tokens",
|
||||
)?)
|
||||
.bind(optional_i64(
|
||||
finalization.usage.image_input_tokens,
|
||||
"image_input_tokens",
|
||||
)?)
|
||||
.bind(optional_i64(
|
||||
finalization.usage.image_output_tokens,
|
||||
"image_output_tokens",
|
||||
)?)
|
||||
.bind(optional_i64(
|
||||
finalization.usage.total_tokens,
|
||||
"total_tokens",
|
||||
)?)
|
||||
.bind(finalization.image_generation_succeeded)
|
||||
.bind(finalization.cost_source.as_str())
|
||||
.bind(finalization.cost_amount.map(|amount| amount.to_string()))
|
||||
.bind(finalization.cost_currency)
|
||||
@@ -569,6 +593,7 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
.bind(finalization.completed_at)
|
||||
.bind(finalization.upstream_transport)
|
||||
.bind(finalization.http_version)
|
||||
.bind(finalization.websocket_pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("finalize model request"))?;
|
||||
@@ -582,7 +607,11 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
let result = sqlx::query(
|
||||
"update model_requests
|
||||
set outcome = 'incomplete', error_kind = 'process_interrupted',
|
||||
error_message = 'request did not reach a terminal state', completed_at = $1
|
||||
error_message = 'request did not reach a terminal state',
|
||||
image_generation_succeeded = case
|
||||
when image_generation_requested then false else null
|
||||
end,
|
||||
completed_at = $1
|
||||
where outcome = 'running' and deadline_at <= $1",
|
||||
)
|
||||
.bind(now)
|
||||
@@ -622,6 +651,7 @@ impl ExecutionStore for PgExecutionStore {
|
||||
request_kind: request.request_kind,
|
||||
subagent_kind: request.subagent_kind,
|
||||
compact: request.compact,
|
||||
image_generation_requested: request.image_generation_requested,
|
||||
started_at: DateTime::<Utc>::from(request.started_at),
|
||||
deadline_at: DateTime::<Utc>::from(request.deadline_at),
|
||||
})
|
||||
@@ -790,11 +820,13 @@ impl ExecutionStore for PgExecutionStore {
|
||||
upstream_response_id: finalization.upstream_response_id,
|
||||
upstream_transport: finalization.upstream_transport,
|
||||
http_version: finalization.http_version,
|
||||
websocket_pool: finalization.websocket_pool,
|
||||
error_kind,
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
error_message,
|
||||
retry_after_ms: finalization.retry_after_ms,
|
||||
usage: usage_from_core(finalization.usage),
|
||||
image_generation_succeeded: finalization.image_generation_succeeded,
|
||||
cost_source,
|
||||
cost_amount,
|
||||
cost_currency,
|
||||
@@ -838,6 +870,8 @@ fn usage_from_core(usage: CoreUsage) -> ModelRequestUsage {
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ use gateway_core::engine::continuation::{NativeContinuationPort, NativeContinuat
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
continuation::{
|
||||
NativeContinuationPin, NativeContinuationReuse, NativeContinuationScope,
|
||||
PreviousResponseId,
|
||||
},
|
||||
continuation::{NativeContinuationPin, NativeContinuationScope, PreviousResponseId},
|
||||
credential::ProviderAccountId,
|
||||
},
|
||||
error::SafeUpstreamValue,
|
||||
@@ -52,7 +49,6 @@ pub trait ModelRequestHistoryRepository: Send + Sync {
|
||||
&self,
|
||||
client_response_id: &str,
|
||||
caller_client_api_key_ref: &str,
|
||||
reuse: NativeContinuationReuse,
|
||||
) -> StoreResult<Option<NativeContinuationPin>>;
|
||||
}
|
||||
|
||||
@@ -105,7 +101,6 @@ impl ModelRequestHistoryRepository for PgHistoryRepository {
|
||||
&self,
|
||||
client_response_id: &str,
|
||||
caller_client_api_key_ref: &str,
|
||||
reuse: NativeContinuationReuse,
|
||||
) -> StoreResult<Option<NativeContinuationPin>> {
|
||||
require_nonempty(
|
||||
"native continuation",
|
||||
@@ -139,7 +134,7 @@ impl ModelRequestHistoryRepository for PgHistoryRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("resolve native continuation pin"))?;
|
||||
row.map(|row| native_pin_from_row(client_response_id, row, reuse))
|
||||
row.map(|row| native_pin_from_row(client_response_id, row))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
@@ -157,7 +152,6 @@ impl NativeContinuationPort for PgHistoryRepository {
|
||||
self.resolve_native_continuation_pin(
|
||||
previous_response_id.as_str(),
|
||||
client_api_key_id.as_str(),
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| NativeContinuationStoreError)
|
||||
@@ -245,7 +239,6 @@ fn history_from_row(row: HistoryRow) -> ModelRequestHistoryRecord {
|
||||
fn native_pin_from_row(
|
||||
client_response_id: &str,
|
||||
row: (String, String, String, String),
|
||||
reuse: NativeContinuationReuse,
|
||||
) -> StoreResult<NativeContinuationPin> {
|
||||
let previous_response_id = PreviousResponseId::new(client_response_id.to_owned())
|
||||
.map_err(|_| invalid_native_pin("invalid client response ID"))?;
|
||||
@@ -263,7 +256,6 @@ fn native_pin_from_row(
|
||||
provider,
|
||||
instance,
|
||||
account,
|
||||
reuse,
|
||||
)
|
||||
.with_scope(NativeContinuationScope::Persisted))
|
||||
}
|
||||
|
||||
@@ -391,6 +391,10 @@ pub struct ProviderAccountUsageObservation {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCostTotal>,
|
||||
@@ -408,6 +412,10 @@ pub struct ProviderAccountModelUsageObservation {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCostTotal>,
|
||||
@@ -479,6 +487,7 @@ pub struct UsageRecord {
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub upstream_send_state: String,
|
||||
pub downstream_committed_at: Option<DateTime<Utc>>,
|
||||
@@ -497,6 +506,8 @@ pub struct UsageRecord {
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_source: String,
|
||||
pub cost_amount: Option<DecimalAmount>,
|
||||
@@ -517,6 +528,8 @@ pub struct UsageRecord {
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: bool,
|
||||
pub image_generation_requested: bool,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub deadline_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
@@ -1190,6 +1203,10 @@ fn admin_dashboard_account_usage(
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
total_tokens: usage.total_tokens,
|
||||
cost_coverage: admin_cost_coverage(usage.cost_coverage),
|
||||
costs: admin_currency_costs(usage.costs)?,
|
||||
@@ -1214,6 +1231,10 @@ fn admin_dashboard_account_model_usage(
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
total_tokens: usage.total_tokens,
|
||||
cost_coverage: admin_cost_coverage(usage.cost_coverage),
|
||||
costs: admin_currency_costs(usage.costs)?,
|
||||
@@ -1297,6 +1318,7 @@ fn admin_usage_record(record: UsageRecord) -> AdminStoreResult<admin_observabili
|
||||
upstream_model_id: record.upstream_model_id,
|
||||
upstream_transport: record.upstream_transport,
|
||||
http_version: record.http_version,
|
||||
websocket_pool: record.websocket_pool,
|
||||
attempt_count: record.attempt_count,
|
||||
upstream_send_state: record.upstream_send_state,
|
||||
downstream_committed_at: record.downstream_committed_at,
|
||||
@@ -1315,6 +1337,8 @@ fn admin_usage_record(record: UsageRecord) -> AdminStoreResult<admin_observabili
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
reasoning_tokens: record.reasoning_tokens,
|
||||
image_input_tokens: record.image_input_tokens,
|
||||
image_output_tokens: record.image_output_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
cost_source: record.cost_source,
|
||||
cost_amount: admin_optional_decimal_amount(record.cost_amount)?,
|
||||
@@ -1336,6 +1360,8 @@ fn admin_usage_record(record: UsageRecord) -> AdminStoreResult<admin_observabili
|
||||
request_kind: record.request_kind,
|
||||
subagent_kind: record.subagent_kind,
|
||||
compact: record.compact,
|
||||
image_generation_requested: record.image_generation_requested,
|
||||
image_generation_succeeded: record.image_generation_succeeded,
|
||||
started_at: record.started_at,
|
||||
deadline_at: record.deadline_at,
|
||||
completed_at: record.completed_at,
|
||||
@@ -2029,6 +2055,12 @@ async fn provider_account_usage(
|
||||
sum(mr.cached_tokens)::bigint as cached_tokens,
|
||||
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
|
||||
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
|
||||
sum(mr.image_input_tokens)::bigint as image_input_tokens,
|
||||
sum(mr.image_output_tokens)::bigint as image_output_tokens,
|
||||
count(mr.id) filter (where mr.image_generation_succeeded is true)::bigint
|
||||
as image_request_count,
|
||||
count(mr.id) filter (where mr.image_generation_succeeded is false)::bigint
|
||||
as image_request_failed_count,
|
||||
sum(mr.total_tokens)::bigint as total_tokens,
|
||||
count(mr.id) filter (where mr.cost_source = 'provider_reported')::bigint
|
||||
as provider_reported_count,
|
||||
@@ -2107,6 +2139,10 @@ async fn provider_account_usage(
|
||||
cached_tokens: optional_unsigned(&row, "cached_tokens")?,
|
||||
cache_write_tokens: optional_unsigned(&row, "cache_write_tokens")?,
|
||||
reasoning_tokens: optional_unsigned(&row, "reasoning_tokens")?,
|
||||
image_input_tokens: optional_unsigned(&row, "image_input_tokens")?,
|
||||
image_output_tokens: optional_unsigned(&row, "image_output_tokens")?,
|
||||
image_request_count: unsigned(&row, "image_request_count")?,
|
||||
image_request_failed_count: unsigned(&row, "image_request_failed_count")?,
|
||||
total_tokens: optional_unsigned(&row, "total_tokens")?,
|
||||
cost_coverage: coverage_from_row(&row)?,
|
||||
last_used_at: get(&row, "last_used_at")?,
|
||||
@@ -2160,6 +2196,12 @@ async fn account_model_rows(
|
||||
sum(cached_tokens)::bigint as cached_tokens,
|
||||
sum(cache_write_tokens)::bigint as cache_write_tokens,
|
||||
sum(reasoning_tokens)::bigint as reasoning_tokens,
|
||||
sum(image_input_tokens)::bigint as image_input_tokens,
|
||||
sum(image_output_tokens)::bigint as image_output_tokens,
|
||||
count(*) filter (where image_generation_succeeded is true)::bigint
|
||||
as image_request_count,
|
||||
count(*) filter (where image_generation_succeeded is false)::bigint
|
||||
as image_request_failed_count,
|
||||
sum(total_tokens)::bigint as total_tokens,
|
||||
count(*) filter (where cost_source = 'provider_reported')::bigint
|
||||
as provider_reported_count,
|
||||
@@ -2234,6 +2276,10 @@ fn provider_account_from_row(
|
||||
cached_tokens: optional_unsigned(row, "cached_tokens")?,
|
||||
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
|
||||
reasoning_tokens: optional_unsigned(row, "reasoning_tokens")?,
|
||||
image_input_tokens: optional_unsigned(row, "image_input_tokens")?,
|
||||
image_output_tokens: optional_unsigned(row, "image_output_tokens")?,
|
||||
image_request_count: unsigned(row, "image_request_count")?,
|
||||
image_request_failed_count: unsigned(row, "image_request_failed_count")?,
|
||||
total_tokens: optional_unsigned(row, "total_tokens")?,
|
||||
cost_coverage: coverage_from_row(row)?,
|
||||
costs: Vec::new(),
|
||||
@@ -2247,18 +2293,20 @@ const USAGE_RECORD_SELECT: &str =
|
||||
mr.endpoint, mr.client_transport, mr.requested_model_id,
|
||||
mr.input_token_estimate, mr.provider_instance_id, mr.provider_kind, mr.provider_account_ref,
|
||||
pa.name as provider_account_name, pa.email as provider_account_email,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.http_version,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.http_version, mr.websocket_pool,
|
||||
mr.attempt_count, mr.upstream_send_state, mr.downstream_committed_at,
|
||||
mr.outcome, mr.client_status_code, mr.upstream_status_code,
|
||||
mr.client_response_id, mr.upstream_request_id, mr.upstream_response_id,
|
||||
mr.error_kind, mr.provider_error_code, mr.error_message, mr.retry_after_ms,
|
||||
mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens,
|
||||
mr.reasoning_tokens, mr.total_tokens, mr.cost_source, mr.cost_amount::text,
|
||||
mr.reasoning_tokens, mr.image_input_tokens, mr.image_output_tokens,
|
||||
mr.total_tokens, mr.cost_source, mr.cost_amount::text,
|
||||
mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
|
||||
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
|
||||
mr.provider_processing_ms, mr.latency_ms, mr.client_ip::text as client_ip,
|
||||
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
|
||||
mr.subagent_kind, mr.compact, mr.started_at, mr.deadline_at, mr.completed_at
|
||||
mr.subagent_kind, mr.compact, mr.image_generation_requested,
|
||||
mr.image_generation_succeeded, mr.started_at, mr.deadline_at, mr.completed_at
|
||||
from model_requests mr
|
||||
left join provider_accounts pa on pa.id = mr.provider_account_id";
|
||||
|
||||
@@ -2735,6 +2783,7 @@ fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
http_version: get(row, "http_version")?,
|
||||
websocket_pool: get(row, "websocket_pool")?,
|
||||
attempt_count: to_u32(get(row, "attempt_count")?)?,
|
||||
upstream_send_state: get(row, "upstream_send_state")?,
|
||||
downstream_committed_at: get(row, "downstream_committed_at")?,
|
||||
@@ -2753,6 +2802,8 @@ fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord
|
||||
cached_tokens: optional_unsigned(row, "cached_tokens")?,
|
||||
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
|
||||
reasoning_tokens: optional_unsigned(row, "reasoning_tokens")?,
|
||||
image_input_tokens: optional_unsigned(row, "image_input_tokens")?,
|
||||
image_output_tokens: optional_unsigned(row, "image_output_tokens")?,
|
||||
total_tokens: optional_unsigned(row, "total_tokens")?,
|
||||
cost_source: get(row, "cost_source")?,
|
||||
cost_amount: optional_decimal(row, "cost_amount")?,
|
||||
@@ -2773,6 +2824,8 @@ fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord
|
||||
request_kind: get(row, "request_kind")?,
|
||||
subagent_kind: get(row, "subagent_kind")?,
|
||||
compact: get(row, "compact")?,
|
||||
image_generation_requested: get(row, "image_generation_requested")?,
|
||||
image_generation_succeeded: get(row, "image_generation_succeeded")?,
|
||||
started_at: get(row, "started_at")?,
|
||||
deadline_at: get(row, "deadline_at")?,
|
||||
completed_at: get(row, "completed_at")?,
|
||||
|
||||
@@ -1764,6 +1764,10 @@ fn admin_account_usage(usage: ProviderAccountUsageObservation) -> AdminStoreResu
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
total_tokens: usage.total_tokens,
|
||||
cost_coverage: admin_account_cost_coverage(usage.cost_coverage),
|
||||
costs: admin_account_costs(usage.costs)?,
|
||||
@@ -1788,6 +1792,10 @@ fn admin_account_model_usage(
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
total_tokens: usage.total_tokens,
|
||||
cost_coverage: admin_account_cost_coverage(usage.cost_coverage),
|
||||
costs: admin_account_costs(usage.costs)?,
|
||||
|
||||
@@ -11,34 +11,13 @@ use gateway_core::routing::{
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Revision, StoreError, StoreResult, redis_unavailable, require_nonempty};
|
||||
use crate::{Revision, StoreError, StoreResult, redis_unavailable};
|
||||
|
||||
use super::{namespace, resource_fingerprint};
|
||||
use super::namespace;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RuntimeChange {
|
||||
SnapshotPublished {
|
||||
config_revision: Revision,
|
||||
},
|
||||
ProviderAccountChanged {
|
||||
provider_account_fingerprint: String,
|
||||
credential_revision: Revision,
|
||||
},
|
||||
}
|
||||
|
||||
impl RuntimeChange {
|
||||
pub fn provider_account_changed(
|
||||
provider_account_id: &str,
|
||||
credential_revision: Revision,
|
||||
) -> StoreResult<Self> {
|
||||
Ok(Self::ProviderAccountChanged {
|
||||
provider_account_fingerprint: resource_fingerprint(
|
||||
"runtime change",
|
||||
provider_account_id,
|
||||
)?,
|
||||
credential_revision,
|
||||
})
|
||||
}
|
||||
SnapshotPublished { config_revision: Revision },
|
||||
}
|
||||
|
||||
pub type RuntimeChangeSubscription =
|
||||
@@ -134,7 +113,6 @@ impl SnapshotSubscriptionPort for RedisRuntimeChangeRepository {
|
||||
ConfigRevision::new(config_revision.get())
|
||||
.map_err(|_| SnapshotSubscriptionError::unavailable()),
|
||||
),
|
||||
Ok(RuntimeChange::ProviderAccountChanged { .. }) => None,
|
||||
Err(_) => Some(Err(SnapshotSubscriptionError::unavailable())),
|
||||
}
|
||||
});
|
||||
@@ -147,7 +125,6 @@ impl SnapshotSubscriptionPort for RedisRuntimeChangeRepository {
|
||||
struct RuntimeChangeWire {
|
||||
kind: String,
|
||||
revision: u64,
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&RuntimeChange> for RuntimeChangeWire {
|
||||
@@ -156,15 +133,6 @@ impl From<&RuntimeChange> for RuntimeChangeWire {
|
||||
RuntimeChange::SnapshotPublished { config_revision } => Self {
|
||||
kind: "snapshot".to_owned(),
|
||||
revision: config_revision.get(),
|
||||
account: None,
|
||||
},
|
||||
RuntimeChange::ProviderAccountChanged {
|
||||
provider_account_fingerprint,
|
||||
credential_revision,
|
||||
} => Self {
|
||||
kind: "provider_account".to_owned(),
|
||||
revision: credential_revision.get(),
|
||||
account: Some(provider_account_fingerprint.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -176,19 +144,9 @@ impl TryFrom<RuntimeChangeWire> for RuntimeChange {
|
||||
fn try_from(value: RuntimeChangeWire) -> Result<Self, Self::Error> {
|
||||
let revision = Revision::new(value.revision)?;
|
||||
match value.kind.as_str() {
|
||||
"snapshot" if value.account.is_none() => Ok(Self::SnapshotPublished {
|
||||
"snapshot" => Ok(Self::SnapshotPublished {
|
||||
config_revision: revision,
|
||||
}),
|
||||
"provider_account" => {
|
||||
let account = value
|
||||
.account
|
||||
.ok_or_else(|| invalid("provider account fingerprint is missing"))?;
|
||||
require_nonempty("runtime change", "account", &account)?;
|
||||
Ok(Self::ProviderAccountChanged {
|
||||
provider_account_fingerprint: account,
|
||||
credential_revision: revision,
|
||||
})
|
||||
}
|
||||
_ => Err(invalid("runtime change kind is invalid")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::accounting::{CalculatedCost, Usage};
|
||||
use gateway_core::accounting::{CalculatedCost, CostEstimate, Usage};
|
||||
use gateway_core::engine::{
|
||||
ExecutionOutcome, ExecutionStore, ModelRequestFinalization as CoreModelRequestFinalization,
|
||||
ModelRequestId, ModelRequestTimings as CoreModelRequestTimings, UpstreamSendState,
|
||||
@@ -35,6 +35,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
|
||||
request_kind: None,
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: false,
|
||||
started_at,
|
||||
deadline_at: started_at + Duration::seconds(30),
|
||||
};
|
||||
@@ -136,36 +137,17 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
|
||||
.expect("seed model request attempt");
|
||||
let repository = PgExecutionStore::new(database.pool.clone());
|
||||
|
||||
ExecutionStore::finalize_model_request(
|
||||
&repository,
|
||||
CoreModelRequestFinalization {
|
||||
request_id: ModelRequestId::new("req_calculated_cost").expect("request id"),
|
||||
outcome: ExecutionOutcome::Succeeded,
|
||||
send_state: UpstreamSendState::Sent,
|
||||
attempt_count: 1,
|
||||
downstream_committed_at: None,
|
||||
client_status_code: Some(200),
|
||||
client_response_id: None,
|
||||
upstream_status_code: Some(200),
|
||||
upstream_request_id: None,
|
||||
upstream_response_id: None,
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
http_version: Some("HTTP/2".to_owned()),
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
retry_after_ms: None,
|
||||
usage: Usage::new(),
|
||||
cost: CalculatedCost::from_usd_ticks(12_345)
|
||||
.expect("calculated cost")
|
||||
.into_estimate(),
|
||||
timings: CoreModelRequestTimings::default(),
|
||||
completed_at: std::time::SystemTime::now(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("persist calculated cost");
|
||||
let persisted: (String, String, String, String, String) = sqlx::query_as(
|
||||
"select cost_source, cost_amount::text, cost_currency, upstream_transport, http_version
|
||||
let mut finalization = successful_core_finalization("req_calculated_cost");
|
||||
finalization.websocket_pool = Some("reuse".to_owned());
|
||||
finalization.cost = CalculatedCost::from_usd_ticks(12_345)
|
||||
.expect("calculated cost")
|
||||
.into_estimate();
|
||||
ExecutionStore::finalize_model_request(&repository, finalization)
|
||||
.await
|
||||
.expect("persist calculated cost");
|
||||
let persisted: (String, String, String, String, String, String) = sqlx::query_as(
|
||||
"select cost_source, cost_amount::text, cost_currency, upstream_transport, http_version,
|
||||
websocket_pool
|
||||
from model_requests where id = 'req_calculated_cost'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
@@ -180,11 +162,120 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
|
||||
"USD".to_owned(),
|
||||
"websocket".to_owned(),
|
||||
"HTTP/2".to_owned(),
|
||||
"reuse".to_owned(),
|
||||
)
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_adapter_should_persist_image_result_and_new_websocket_pool() {
|
||||
let Some(database) = TestDatabase::create("execution_image_usage").await else {
|
||||
return;
|
||||
};
|
||||
seed_running_request(&database.pool, "req_image_usage")
|
||||
.await
|
||||
.expect("seed image request");
|
||||
sqlx::query(
|
||||
"update model_requests
|
||||
set image_generation_requested = true, provider_kind = 'openai',
|
||||
provider_account_ref = 'acct_openai', upstream_model_id = 'gpt-image',
|
||||
upstream_transport = 'websocket', attempt_count = 1
|
||||
where id = 'req_image_usage'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed image attempt");
|
||||
let repository = PgExecutionStore::new(database.pool.clone());
|
||||
let mut usage = Usage::new();
|
||||
usage.image_input_tokens = Some(31);
|
||||
usage.image_output_tokens = Some(9);
|
||||
let mut finalization = successful_core_finalization("req_image_usage");
|
||||
finalization.usage = usage;
|
||||
finalization.image_generation_succeeded = Some(true);
|
||||
finalization.websocket_pool = Some("new".to_owned());
|
||||
|
||||
ExecutionStore::finalize_model_request(&repository, finalization)
|
||||
.await
|
||||
.expect("persist image usage");
|
||||
let persisted: (bool, Option<i64>, Option<i64>, Option<bool>, Option<String>) = sqlx::query_as(
|
||||
"select image_generation_requested, image_input_tokens, image_output_tokens,
|
||||
image_generation_succeeded, websocket_pool
|
||||
from model_requests where id = 'req_image_usage'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load image usage");
|
||||
|
||||
assert_eq!(
|
||||
persisted,
|
||||
(true, Some(31), Some(9), Some(true), Some("new".to_owned()))
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_image_request_should_be_recovered_as_failed() {
|
||||
let Some(database) = TestDatabase::create("execution_expired_image").await else {
|
||||
return;
|
||||
};
|
||||
seed_running_request(&database.pool, "req_expired_image")
|
||||
.await
|
||||
.expect("seed image request");
|
||||
sqlx::query(
|
||||
"update model_requests
|
||||
set image_generation_requested = true,
|
||||
started_at = now() - interval '2 seconds',
|
||||
deadline_at = now() - interval '1 second'
|
||||
where id = 'req_expired_image'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("expire image request");
|
||||
let repository = PgExecutionStore::new(database.pool.clone());
|
||||
|
||||
repository
|
||||
.recover_expired_model_requests(Utc::now())
|
||||
.await
|
||||
.expect("recover expired image request");
|
||||
let persisted: (String, Option<bool>) = sqlx::query_as(
|
||||
"select outcome, image_generation_succeeded
|
||||
from model_requests where id = 'req_expired_image'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load recovered image request");
|
||||
|
||||
assert_eq!(persisted, ("incomplete".to_owned(), Some(false)));
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
fn successful_core_finalization(id: &str) -> CoreModelRequestFinalization {
|
||||
CoreModelRequestFinalization {
|
||||
request_id: ModelRequestId::new(id).expect("request id"),
|
||||
outcome: ExecutionOutcome::Succeeded,
|
||||
send_state: UpstreamSendState::Sent,
|
||||
attempt_count: 1,
|
||||
downstream_committed_at: None,
|
||||
client_status_code: Some(200),
|
||||
client_response_id: None,
|
||||
upstream_status_code: Some(200),
|
||||
upstream_request_id: None,
|
||||
upstream_response_id: None,
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
http_version: Some("HTTP/2".to_owned()),
|
||||
websocket_pool: None,
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
retry_after_ms: None,
|
||||
usage: Usage::new(),
|
||||
image_generation_succeeded: None,
|
||||
cost: CostEstimate::unavailable(),
|
||||
timings: CoreModelRequestTimings::default(),
|
||||
completed_at: std::time::SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_running_request(pool: &sqlx::PgPool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use gateway_core::engine::continuation::NativeContinuationReuse;
|
||||
use gateway_store::postgres::{
|
||||
ModelRequestHistoryRecord, ModelRequestHistoryRepository, PgHistoryRepository,
|
||||
};
|
||||
@@ -36,11 +35,7 @@ async fn native_pin_is_caller_scoped_and_uses_live_account_and_upstream_handle()
|
||||
let repository = PgHistoryRepository::new(database.pool.clone());
|
||||
|
||||
let pin = repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner")
|
||||
.await
|
||||
.expect("resolve native pin")
|
||||
.expect("eligible pin");
|
||||
@@ -52,11 +47,7 @@ async fn native_pin_is_caller_scoped_and_uses_live_account_and_upstream_handle()
|
||||
assert!(!debug.contains("resp_upstream_native"));
|
||||
|
||||
let other_client = repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_other",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_other")
|
||||
.await
|
||||
.expect("resolve other caller");
|
||||
assert!(other_client.is_none());
|
||||
@@ -66,11 +57,7 @@ async fn native_pin_is_caller_scoped_and_uses_live_account_and_upstream_handle()
|
||||
.await
|
||||
.expect("delete live account while preserving historical ref");
|
||||
let deleted_account = repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner")
|
||||
.await
|
||||
.expect("resolve deleted account");
|
||||
assert!(deleted_account.is_none());
|
||||
@@ -103,11 +90,7 @@ async fn native_pin_requires_committed_success_but_not_a_specific_transport() {
|
||||
.expect("clear downstream commit");
|
||||
assert!(
|
||||
repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner",)
|
||||
.await
|
||||
.expect("resolve uncommitted request")
|
||||
.is_none()
|
||||
@@ -123,11 +106,7 @@ async fn native_pin_requires_committed_success_but_not_a_specific_transport() {
|
||||
.expect("mark request failed");
|
||||
assert!(
|
||||
repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner",)
|
||||
.await
|
||||
.expect("resolve failed request")
|
||||
.is_none()
|
||||
@@ -139,11 +118,7 @@ async fn native_pin_requires_committed_success_but_not_a_specific_transport() {
|
||||
.expect("restore successful request");
|
||||
assert!(
|
||||
repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner",)
|
||||
.await
|
||||
.expect("resolve restored request")
|
||||
.is_some()
|
||||
@@ -157,11 +132,7 @@ async fn native_pin_requires_committed_success_but_not_a_specific_transport() {
|
||||
.expect("mark response connection-independent");
|
||||
assert!(
|
||||
repository
|
||||
.resolve_native_continuation_pin(
|
||||
"resp_gateway_visible",
|
||||
"key_owner",
|
||||
NativeContinuationReuse::Reusable,
|
||||
)
|
||||
.resolve_native_continuation_pin("resp_gateway_visible", "key_owner",)
|
||||
.await
|
||||
.expect("resolve HTTP response")
|
||||
.is_some()
|
||||
|
||||
@@ -406,6 +406,17 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
assert_eq!(account_usage[0].cost_coverage.provider_reported_count, 1);
|
||||
assert_eq!(account_usage[0].cost_coverage.unavailable_count, 1);
|
||||
assert_eq!(account_usage[0].costs[0].amount.as_str(), "1.25");
|
||||
assert_eq!(
|
||||
(
|
||||
account_usage[0].image_input_tokens,
|
||||
account_usage[0].image_output_tokens,
|
||||
account_usage[0].image_request_count,
|
||||
account_usage[0].image_request_failed_count,
|
||||
account_usage[0].models[0].image_request_count,
|
||||
account_usage[0].models[0].image_request_failed_count,
|
||||
),
|
||||
(Some(31), Some(9), 1, 1, 1, 1)
|
||||
);
|
||||
|
||||
let usage_page = repository
|
||||
.list_usage_records(UsageRecordQuery {
|
||||
@@ -421,6 +432,21 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.await
|
||||
.expect("usage records");
|
||||
assert_eq!(usage_page.total, 2);
|
||||
let successful_image = usage_page
|
||||
.items
|
||||
.iter()
|
||||
.find(|record| record.id == "req_observe_success")
|
||||
.expect("successful image usage record");
|
||||
assert_eq!(
|
||||
(
|
||||
successful_image.websocket_pool.as_deref(),
|
||||
successful_image.image_input_tokens,
|
||||
successful_image.image_output_tokens,
|
||||
successful_image.image_generation_requested,
|
||||
successful_image.image_generation_succeeded,
|
||||
),
|
||||
(Some("reuse"), Some(31), Some(9), true, Some(true))
|
||||
);
|
||||
|
||||
let detail = repository
|
||||
.usage_record_detail("req_observe_failed")
|
||||
@@ -522,19 +548,22 @@ async fn seed_observability_facts(
|
||||
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
|
||||
client_transport, requested_model_id, input_token_estimate,
|
||||
provider_instance_id, provider_kind, provider_account_id,
|
||||
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
|
||||
provider_account_ref, upstream_model_id, upstream_transport, websocket_pool,
|
||||
attempt_count,
|
||||
upstream_send_state, downstream_committed_at, outcome, client_status_code,
|
||||
upstream_status_code, client_response_id, upstream_request_id, upstream_response_id,
|
||||
input_tokens, output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens,
|
||||
total_tokens, cost_source, cost_amount, cost_currency, first_token_ms, latency_ms,
|
||||
image_input_tokens, image_output_tokens, total_tokens,
|
||||
image_generation_requested, image_generation_succeeded,
|
||||
cost_source, cost_amount, cost_currency, first_token_ms, latency_ms,
|
||||
started_at, deadline_at, completed_at
|
||||
) values (
|
||||
'req_observe_success', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
|
||||
'http_sse', 'public-model', 100, 'inst_observe', 'openai', 'acct_observe',
|
||||
'acct_observe', 'upstream-model',
|
||||
'http_sse', 1, 'sent', $1 - interval '19 minutes', 'succeeded', 200, 200,
|
||||
'http_sse', 'reuse', 1, 'sent', $1 - interval '19 minutes', 'succeeded', 200, 200,
|
||||
'resp_observe_success', 'upstream_req_success', 'upstream_resp_success',
|
||||
100, 20, 40, 3, 5, 120,
|
||||
100, 20, 40, 3, 5, 31, 9, 120, true, true,
|
||||
'provider_reported', 1.25, 'USD', 120, 900,
|
||||
$1 - interval '20 minutes', $1 + interval '10 minutes', $1 - interval '19 minutes'
|
||||
)",
|
||||
@@ -550,14 +579,15 @@ async fn seed_observability_facts(
|
||||
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
|
||||
upstream_send_state, outcome, client_status_code, upstream_status_code,
|
||||
error_kind, provider_error_code, error_message, retry_after_ms,
|
||||
input_tokens, cached_tokens, cost_source, latency_ms,
|
||||
input_tokens, cached_tokens, image_generation_requested,
|
||||
image_generation_succeeded, cost_source, latency_ms,
|
||||
started_at, deadline_at, completed_at
|
||||
) values (
|
||||
'req_observe_failed', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
|
||||
'http_sse', 'public-model', 80, 'inst_observe', 'openai', 'acct_observe',
|
||||
'acct_observe', 'upstream-model',
|
||||
'http_sse', 2, 'sent', 'failed', 502, 429, 'rate_limited', 'rate_limit',
|
||||
'upstream limited', 1000, 0, 0, 'unavailable', 700,
|
||||
'upstream limited', 1000, 0, 0, true, false, 'unavailable', 700,
|
||||
$1 - interval '10 minutes', $1 + interval '20 minutes', $1 - interval '9 minutes'
|
||||
)",
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ mod credential_leases;
|
||||
mod credential_state;
|
||||
mod oauth_pending;
|
||||
mod provider_circuit;
|
||||
mod runtime_change;
|
||||
mod worker_lease;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use gateway_store::{Revision, redis::RuntimeChange};
|
||||
|
||||
#[test]
|
||||
fn runtime_change_does_not_expose_raw_account_id() {
|
||||
let account_id = "real-account-id";
|
||||
let change = RuntimeChange::provider_account_changed(
|
||||
account_id,
|
||||
Revision::new(1).expect("positive revision"),
|
||||
)
|
||||
.expect("valid change");
|
||||
assert!(!format!("{change:?}").contains(account_id));
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
//! Worker leader lease 通过 StoreBundle 的中立能力进行集成验证。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use gateway_core::task::{WorkerId, WorkerKind, WorkerLeaseAcquisition, WorkerLeaseRequest};
|
||||
use gateway_store::{StoreConfig, initialize};
|
||||
use sqlx::{
|
||||
ConnectOptions as _, PgPool,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -15,7 +20,8 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let config = store_config(&database_url, &redis_url);
|
||||
let database = TestDatabase::create(&database_url).await;
|
||||
let config = store_config(&database.url, &redis_url);
|
||||
let mut first = initialize(config.clone())
|
||||
.await
|
||||
.expect("first Store bundle");
|
||||
@@ -29,7 +35,6 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
|
||||
assert_eq!(
|
||||
kinds,
|
||||
BTreeSet::from([
|
||||
WorkerKind::NativeClaimRecovery,
|
||||
WorkerKind::StaleModelRequestRecovery,
|
||||
WorkerKind::Retention,
|
||||
WorkerKind::OpsFlush,
|
||||
@@ -75,6 +80,48 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
|
||||
};
|
||||
assert!(second_guard.fencing_token() > first_token);
|
||||
second_guard.release().await.expect("second lease release");
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
struct TestDatabase {
|
||||
admin: PgPool,
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl TestDatabase {
|
||||
async fn create(database_url: &str) -> Self {
|
||||
let name = format!("cpr_store_worker_{}", Uuid::new_v4().simple());
|
||||
let admin = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.expect("connect test PostgreSQL");
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!("create database \"{name}\"")))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.expect("create worker test database");
|
||||
let url = PgConnectOptions::from_str(database_url)
|
||||
.expect("parse test PostgreSQL URL")
|
||||
.database(&name)
|
||||
.to_url_lossy()
|
||||
.to_string();
|
||||
Self { admin, name, url }
|
||||
}
|
||||
|
||||
async fn close(self) {
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
|
||||
"drop database \"{}\" with (force)",
|
||||
self.name
|
||||
)))
|
||||
.execute(&self.admin)
|
||||
.await
|
||||
.expect("drop worker test database");
|
||||
self.admin.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn store_config(database_url: &str, redis_url: &str) -> StoreConfig {
|
||||
|
||||
@@ -88,10 +88,6 @@ impl std::fmt::Debug for ImportCodexOAuthCredential {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImportCodexOAuthCredentialBatch {
|
||||
pub items: Vec<ImportCodexOAuthCredential>,
|
||||
}
|
||||
|
||||
/// Provider-owned 文档归一后的唯一 Core 写入批次。
|
||||
pub struct PreparedCodexAccountImport {
|
||||
accounts: Vec<NewProviderAccount>,
|
||||
@@ -390,25 +386,6 @@ impl CodexCredentialAdmin {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_import_batch(
|
||||
&self,
|
||||
batch: ImportCodexOAuthCredentialBatch,
|
||||
) -> Result<Vec<NewProviderAccount>, CodexCredentialAdminError> {
|
||||
if batch.items.is_empty() || batch.items.len() > MAX_BATCH {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut prepared = Vec::with_capacity(batch.items.len());
|
||||
for item in batch.items {
|
||||
let account = self.prepare_import(item)?;
|
||||
if !ids.insert(account.account.id().clone()) {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
prepared.push(account);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// 严格输出可被旧 CPR import 直接读取的 canonical 文档。
|
||||
pub fn format_cpr_export(
|
||||
&self,
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(crate) use oauth::oauth_owner_ref;
|
||||
pub use admin::{
|
||||
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
|
||||
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
|
||||
ImportCodexOAuthCredentialBatch, PreparedCodexAccountImport, PreparedCodexCredentialRotation,
|
||||
PreparedCodexAccountImport, PreparedCodexCredentialRotation,
|
||||
PreparedCodexCredentialRotationGuard, RotateManagedCodexCredential,
|
||||
};
|
||||
pub use catalog::{
|
||||
|
||||
@@ -140,14 +140,6 @@ impl CodexCredentialRefreshService {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
pub async fn refresh_one(
|
||||
&self,
|
||||
due: DueCodexCredential,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await?;
|
||||
self.refresh_one_with_policy(due, policy).await
|
||||
}
|
||||
|
||||
async fn refresh_one_with_policy(
|
||||
&self,
|
||||
due: DueCodexCredential,
|
||||
|
||||
@@ -19,7 +19,7 @@ use gateway_core::error::{
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
|
||||
ProviderResponseTimings, UpstreamHttpVersion,
|
||||
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
|
||||
use gateway_core::provider_ports::ProviderInstanceCatalogPort;
|
||||
@@ -760,6 +760,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
&response.diagnostics,
|
||||
&response.response_metadata,
|
||||
&response.transport_metrics,
|
||||
response.websocket_pool_decision,
|
||||
)?);
|
||||
synchronize_passive_quota("a, &lease, &response.rate_limit_headers).await;
|
||||
if let Some(etag) = response.response_metadata.models_etag.as_deref()
|
||||
@@ -910,6 +911,7 @@ fn codex_response_observation(
|
||||
diagnostics: &CodexUpstreamDiagnostics,
|
||||
response_metadata: &CodexResponseMetadata,
|
||||
metrics: &CodexTransportMetrics,
|
||||
websocket_pool_decision: Option<crate::transport::WebSocketPoolDecision>,
|
||||
) -> Result<ProviderResponseObservation, ProviderError> {
|
||||
let mut observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(actual_transport_name(transport))
|
||||
@@ -928,6 +930,13 @@ fn codex_response_observation(
|
||||
{
|
||||
observation = observation.with_http_version(version);
|
||||
}
|
||||
if let Some(decision) = websocket_pool_decision {
|
||||
observation = observation.with_websocket_pool(if decision.is_reuse() {
|
||||
WebSocketPoolKind::Reuse
|
||||
} else {
|
||||
WebSocketPoolKind::New
|
||||
});
|
||||
}
|
||||
if let Some(status_code) = diagnostics.status_code {
|
||||
observation = observation.with_status_code(status_code);
|
||||
}
|
||||
@@ -968,6 +977,7 @@ fn codex_error_observation(error: &CodexClientError) -> Option<ProviderResponseO
|
||||
diagnostics,
|
||||
&CodexResponseMetadata::default(),
|
||||
transport_metrics,
|
||||
None,
|
||||
)
|
||||
.ok()?
|
||||
.with_status_code(status.as_u16());
|
||||
|
||||
@@ -615,6 +615,8 @@ fn core_usage(usage: TokenUsage) -> Usage {
|
||||
normalized.cached_tokens = Some(usage.cached_tokens);
|
||||
normalized.cache_write_tokens = Some(usage.cache_write_tokens);
|
||||
normalized.reasoning_tokens = Some(usage.reasoning_tokens);
|
||||
normalized.image_input_tokens = Some(usage.image_input_tokens);
|
||||
normalized.image_output_tokens = Some(usage.image_output_tokens);
|
||||
normalized.total_tokens = Some(usage.total_tokens);
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ use crate::transport::profile::CodexWireProfileState;
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_protocol::openai::{
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY,
|
||||
events::{TokenUsage, retry_after_seconds_from_body},
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, events::retry_after_seconds_from_body,
|
||||
sse::SseError,
|
||||
};
|
||||
use reqwest::{
|
||||
@@ -303,39 +302,6 @@ impl fmt::Debug for CodexRequestContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex Responses 上游响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexBackendResponse {
|
||||
/// 完整 SSE 文本。
|
||||
pub body: String,
|
||||
/// 实际使用的上游传输。
|
||||
pub transport: CodexBackendTransport,
|
||||
/// 从 SSE 中提取出的最终 usage。
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// 响应头里的最新 turn state。
|
||||
pub turn_state: Option<String>,
|
||||
/// 上游透传的 `set-cookie` 列表。
|
||||
pub set_cookie_headers: Vec<String>,
|
||||
/// 上游透传的限流头。
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// 首个有效上游 SSE/WebSocket 事件到达代理的耗时。
|
||||
pub first_token_ms: Option<i64>,
|
||||
/// 首个 reasoning 输出事件到达代理的耗时。
|
||||
pub first_reasoning_ms: Option<i64>,
|
||||
/// 首个正文输出事件到达代理的耗时。
|
||||
pub first_text_ms: Option<i64>,
|
||||
/// WebSocket 连接池决策。
|
||||
pub websocket_pool_decision: Option<WebSocketPoolDecision>,
|
||||
/// 上游诊断元数据。
|
||||
pub diagnostics: CodexUpstreamDiagnostics,
|
||||
/// 安全响应元数据。
|
||||
pub response_metadata: CodexResponseMetadata,
|
||||
/// 传输选择与低延迟阶段耗时。
|
||||
pub transport_metrics: CodexTransportMetrics,
|
||||
/// 当前响应是否由池中 WebSocket 保留 connection-local continuation。
|
||||
pub connection_local_continuation: bool,
|
||||
}
|
||||
|
||||
/// Codex Responses 实际使用的上游传输。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodexBackendTransport {
|
||||
@@ -431,8 +397,6 @@ pub struct CodexBackendClient {
|
||||
pub(super) base_url: String,
|
||||
pub(super) profile: CodexWireProfileState,
|
||||
pub(super) websocket_pool: Option<Arc<CodexWebSocketPool>>,
|
||||
pub(super) websocket_initial_event_timeout: Option<Duration>,
|
||||
pub(super) websocket_fast_path_budget: Duration,
|
||||
pub(super) websocket_origin_breaker: WebSocketOriginBreaker,
|
||||
pub(super) websocket_origin_key: String,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use gateway_protocol::openai::{
|
||||
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
|
||||
events::{extract_sse_usage, retry_after_seconds_from_body},
|
||||
sse::SseEventDecoder,
|
||||
events::retry_after_seconds_from_body,
|
||||
};
|
||||
use reqwest::{
|
||||
Client, Response as ReqwestResponse,
|
||||
@@ -34,9 +30,9 @@ use crate::transport::{
|
||||
websocket::{
|
||||
CodexWebSocketConnection, CodexWebSocketPool, CodexWebSocketPoolKey,
|
||||
DEFAULT_INITIAL_EVENT_TIMEOUT, WEBSOCKET_FAST_PATH_BUDGET, WebSocketOriginBreaker,
|
||||
WebSocketPoolDecision, execute_prepared_response_create_request,
|
||||
execute_prepared_response_create_request_stream, post_send_ambiguous,
|
||||
prepare_response_create_request_with_pool, write_websocket_audit_artifact_from_env,
|
||||
WebSocketPoolDecision, execute_prepared_response_create_request_stream,
|
||||
post_send_ambiguous, prepare_response_create_request_with_pool,
|
||||
write_websocket_audit_artifact_from_env,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,27 +52,10 @@ impl CodexBackendClient {
|
||||
base_url,
|
||||
profile,
|
||||
websocket_pool: None,
|
||||
websocket_initial_event_timeout: Some(DEFAULT_INITIAL_EVENT_TIMEOUT),
|
||||
websocket_fast_path_budget: WEBSOCKET_FAST_PATH_BUDGET,
|
||||
websocket_origin_breaker: WebSocketOriginBreaker::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_websocket_initial_event_timeout(mut self, timeout: Option<Duration>) -> Self {
|
||||
self.websocket_initial_event_timeout = timeout.filter(|timeout| !timeout.is_zero());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_websocket_fast_path_budget(mut self, budget: Duration) -> Self {
|
||||
self.websocket_fast_path_budget = budget.max(Duration::from_millis(1));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_websocket_origin_breaker(mut self, breaker: WebSocketOriginBreaker) -> Self {
|
||||
self.websocket_origin_breaker = breaker;
|
||||
self
|
||||
}
|
||||
|
||||
/// 为 Responses WebSocket 请求启用连接池。
|
||||
pub fn with_websocket_pool(mut self, pool: Arc<CodexWebSocketPool>) -> Self {
|
||||
self.websocket_pool = Some(pool);
|
||||
@@ -90,97 +69,6 @@ impl CodexBackendClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送 Responses SSE 请求并读取完整响应。
|
||||
/// HTTP POST + SSE fallback (when WebSocket pool is disabled).
|
||||
async fn create_response_http_sse(
|
||||
&self,
|
||||
upstream_request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CodexBackendResponse> {
|
||||
let headers = self.request_headers_for_http_response(upstream_request, context)?;
|
||||
let headers_started_at = Instant::now();
|
||||
let response = self
|
||||
.client
|
||||
.post(endpoint_url(&self.base_url, CODEX_RESPONSES_PATH))
|
||||
.headers(headers)
|
||||
.json(&upstream_request)
|
||||
.send()
|
||||
.await?;
|
||||
let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed());
|
||||
let http_version = http_version_name(response.version()).to_string();
|
||||
let status = response.status();
|
||||
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
|
||||
let turn_state = response_meta::turn_state(response.headers());
|
||||
let set_cookie_headers = response_meta::set_cookie_headers(response.headers());
|
||||
let rate_limit_headers = response_meta::rate_limit_headers(response.headers());
|
||||
let response_metadata = response_meta::response_metadata(response.headers());
|
||||
let retry_after_seconds = retry_after_seconds(response.headers(), None);
|
||||
|
||||
if !status.is_success() {
|
||||
let body = read_capped_error_body(response).await?;
|
||||
return Err(CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds: retry_after_seconds
|
||||
.or_else(|| retry_after_seconds_from_body(&body)),
|
||||
body,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
transport: CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::new(CodexTransportMetrics {
|
||||
upstream_headers_ms: Some(upstream_headers_ms),
|
||||
http_version: Some(http_version),
|
||||
..CodexTransportMetrics::default()
|
||||
}),
|
||||
send_phase: CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
|
||||
let mut body_bytes = Vec::new();
|
||||
let mut first_token_ms = None;
|
||||
let mut first_reasoning_ms = None;
|
||||
let mut first_text_ms = None;
|
||||
let mut first_event_ms = None;
|
||||
let mut output_decoder = SseEventDecoder::default();
|
||||
let mut stream = http_sse_stream(response);
|
||||
while let Some(chunk) = stream.try_next().await? {
|
||||
first_event_ms.get_or_insert_with(|| elapsed_duration_millis(started_at.elapsed()));
|
||||
body_bytes.extend_from_slice(&chunk);
|
||||
response_meta::update_response_timing_ms(
|
||||
started_at,
|
||||
&mut output_decoder,
|
||||
&chunk,
|
||||
&mut first_token_ms,
|
||||
&mut first_reasoning_ms,
|
||||
&mut first_text_ms,
|
||||
);
|
||||
}
|
||||
let body = String::from_utf8_lossy(&body_bytes).into_owned();
|
||||
let usage = extract_sse_usage(&body).map_err(CodexClientError::InvalidSse)?;
|
||||
Ok(CodexBackendResponse {
|
||||
body,
|
||||
transport: CodexBackendTransport::HttpSse,
|
||||
usage,
|
||||
turn_state,
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
first_token_ms,
|
||||
first_reasoning_ms,
|
||||
first_text_ms,
|
||||
websocket_pool_decision: None,
|
||||
diagnostics,
|
||||
response_metadata,
|
||||
transport_metrics: CodexTransportMetrics {
|
||||
upstream_headers_ms: Some(upstream_headers_ms),
|
||||
first_event_ms,
|
||||
http_version: Some(http_version),
|
||||
..CodexTransportMetrics::default()
|
||||
},
|
||||
connection_local_continuation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 发送 Responses SSE 请求并返回 live SSE 流(HTTP SSE fallback)。
|
||||
pub(crate) async fn create_response_stream_http_sse(
|
||||
&self,
|
||||
@@ -246,49 +134,6 @@ impl CodexBackendClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_response(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexBackendResponse> {
|
||||
self.create_response_started_at(request, context, Instant::now())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_response_started_at(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CodexBackendResponse> {
|
||||
self.create_response_with_pool_account_started_at(request, context, None, started_at)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_response_with_pool_account_started_at(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
pool_account_id: Option<&str>,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CodexBackendResponse> {
|
||||
let prepared = self
|
||||
.prepare_response_transport_with_pool_account(request, context, pool_account_id)
|
||||
.await?;
|
||||
self.create_response_with_prepared(request, context, prepared, started_at)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送 Responses SSE 请求并返回 live SSE 流。
|
||||
pub async fn create_response_stream(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexBackendStreamingResponse> {
|
||||
self.create_response_stream_with_pool_account(request, context, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_response_stream_with_pool_account(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
@@ -347,7 +192,7 @@ impl CodexBackendClient {
|
||||
let fast_path_budget = match requirement {
|
||||
TransportRequirement::PersistedContinuation
|
||||
| TransportRequirement::ExternalUnknown
|
||||
| TransportRequirement::NewChain => Some(self.websocket_fast_path_budget),
|
||||
| TransportRequirement::NewChain => Some(WEBSOCKET_FAST_PATH_BUDGET),
|
||||
TransportRequirement::ExplicitWebSocketWarmup
|
||||
| TransportRequirement::ExactWebSocketContinuation => None,
|
||||
TransportRequirement::HttpRequired => None,
|
||||
@@ -360,7 +205,7 @@ impl CodexBackendClient {
|
||||
&self.websocket_origin_key,
|
||||
fast_path_budget,
|
||||
requirement.requires_websocket(),
|
||||
self.websocket_initial_event_timeout,
|
||||
Some(DEFAULT_INITIAL_EVENT_TIMEOUT),
|
||||
)
|
||||
.await;
|
||||
let prepared = match prepared {
|
||||
@@ -429,64 +274,6 @@ impl CodexBackendClient {
|
||||
})
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(crate) async fn create_response_with_prepared(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
prepared: PreparedResponseTransport,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CodexBackendResponse> {
|
||||
let PreparedResponseTransport {
|
||||
requirement,
|
||||
route,
|
||||
metrics,
|
||||
} = prepared;
|
||||
let result = match route {
|
||||
PreparedResponseRoute::Http => self
|
||||
.create_response_http_sse(request, context, started_at)
|
||||
.await
|
||||
.map(|mut response| {
|
||||
merge_preparation_metrics(&mut response.transport_metrics, metrics);
|
||||
response
|
||||
}),
|
||||
PreparedResponseRoute::WebSocket(route) => {
|
||||
let PreparedWebSocketRoute { request, prepared } = *route;
|
||||
execute_prepared_response_create_request(&request, prepared, started_at)
|
||||
.await
|
||||
.map_err(websocket_exchange_error_to_client_error)
|
||||
.map(|exchange| CodexBackendResponse {
|
||||
body: exchange.body,
|
||||
transport: CodexBackendTransport::WebSocket,
|
||||
usage: exchange.usage,
|
||||
turn_state: exchange.turn_state,
|
||||
set_cookie_headers: exchange.set_cookie_headers,
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
first_token_ms: exchange.first_token_ms,
|
||||
first_reasoning_ms: exchange.first_reasoning_ms,
|
||||
first_text_ms: exchange.first_text_ms,
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
diagnostics: exchange.diagnostics,
|
||||
response_metadata: exchange.response_metadata,
|
||||
transport_metrics: CodexTransportMetrics {
|
||||
first_event_ms: exchange.first_event_ms,
|
||||
..metrics
|
||||
},
|
||||
connection_local_continuation: exchange.connection_local_continuation,
|
||||
})
|
||||
}
|
||||
};
|
||||
result.inspect_err(|error| {
|
||||
tracing::warn!(
|
||||
request_id = %context.request_id,
|
||||
transport_requirement = requirement.as_str(),
|
||||
failure_phase = "post_send_or_explicit_response",
|
||||
error = %error,
|
||||
"Responses transport failed after preparation; automatic fallback is disabled"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(crate) async fn create_response_stream_with_prepared(
|
||||
&self,
|
||||
|
||||
@@ -287,26 +287,6 @@ impl CodexUpstreamDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_pairs(status_code: Option<u16>, headers: &[(String, String)]) -> Self {
|
||||
let request_id = UPSTREAM_REQUEST_ID_HEADERS
|
||||
.iter()
|
||||
.find_map(|name| pair_value(headers, name));
|
||||
let trace_headers = UPSTREAM_TRACE_HEADERS
|
||||
.iter()
|
||||
.filter_map(|name| pair_value(headers, name).map(|value| ((*name).to_string(), value)))
|
||||
.collect();
|
||||
let identity_authorization_error = pair_value(headers, IDENTITY_AUTHORIZATION_ERROR_HEADER);
|
||||
let identity_error_code = pair_value(headers, IDENTITY_ERROR_JSON_HEADER)
|
||||
.and_then(|encoded| decode_identity_error_code(&encoded));
|
||||
Self {
|
||||
status_code,
|
||||
request_id,
|
||||
identity_authorization_error,
|
||||
identity_error_code,
|
||||
trace_headers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.status_code.is_none()
|
||||
&& self.request_id.is_none()
|
||||
@@ -316,15 +296,6 @@ impl CodexUpstreamDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
fn pair_value(headers: &[(String, String)], name: &str) -> Option<String> {
|
||||
headers
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn first_header(headers: &HeaderMap, names: &[&str]) -> Option<String> {
|
||||
names.iter().find_map(|name| header_value(headers, name))
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ pub use self::{
|
||||
CodexModelCatalogSnapshot, MAX_CODEX_MODEL_CATALOG_BYTES, parse_codex_model_catalog,
|
||||
},
|
||||
client::{
|
||||
CodexBackendClient, CodexBackendResponse, CodexBackendSseStream,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult,
|
||||
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexTransportDecision,
|
||||
CodexTransportMetrics, CodexTurnStateUpdate, build_reqwest_client,
|
||||
CodexBackendClient, CodexBackendSseStream, CodexBackendStreamingResponse,
|
||||
CodexBackendTransport, CodexClientError, CodexClientResult, CodexRateLimitHeaderUpdates,
|
||||
CodexRequestContext, CodexTransportDecision, CodexTransportMetrics, CodexTurnStateUpdate,
|
||||
build_reqwest_client,
|
||||
},
|
||||
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
|
||||
endpoints::{
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::{collections::BTreeSet, fmt};
|
||||
use std::fmt;
|
||||
|
||||
use gateway_protocol::openai::{
|
||||
events,
|
||||
schema::reconvert_tuple_values,
|
||||
sse::{SseError, SseEvent, parse_sse_events},
|
||||
};
|
||||
use gateway_protocol::openai::events;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -24,8 +20,6 @@ const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegatio
|
||||
pub struct CodexResponsesRequest {
|
||||
/// 上游请求体(唯一真相源)。
|
||||
body: Map<String, Value>,
|
||||
/// tuple schema 原始定义,仅供响应重构时使用。
|
||||
pub tuple_schema: Option<Value>,
|
||||
/// 是否由客户端显式提供了 prompt cache key。
|
||||
pub explicit_prompt_cache_key: bool,
|
||||
/// 客户端会话 ID。
|
||||
@@ -79,7 +73,6 @@ impl fmt::Debug for CodexResponsesRequest {
|
||||
formatter
|
||||
.debug_struct("CodexResponsesRequest")
|
||||
.field("body", &"<not included in Debug>")
|
||||
.field("has_tuple_schema", &self.tuple_schema.is_some())
|
||||
.field("explicit_prompt_cache_key", &self.explicit_prompt_cache_key)
|
||||
.field(
|
||||
"has_local_conversation_id",
|
||||
@@ -424,164 +417,6 @@ impl ResponsesSseFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从完成 SSE 中提取会话亲和性和 replay 元数据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompletedResponseMetadata {
|
||||
/// 上游 response id。
|
||||
pub response_id: String,
|
||||
/// 完成响应中的 function call ids。
|
||||
pub function_call_ids: Vec<String>,
|
||||
/// 完成响应的完整 output items。
|
||||
pub output: Vec<Value>,
|
||||
}
|
||||
|
||||
/// 从 Codex Responses SSE 中提取完成响应元数据。
|
||||
pub fn completed_response_metadata(
|
||||
body: &str,
|
||||
) -> Result<Option<CompletedResponseMetadata>, SseError> {
|
||||
let events = parse_sse_events(body)?;
|
||||
let mut response_id = None;
|
||||
let mut function_call_ids = BTreeSet::new();
|
||||
let mut output = Vec::new();
|
||||
let mut completed_items = Vec::new();
|
||||
|
||||
for event in events {
|
||||
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
|
||||
continue;
|
||||
};
|
||||
match event.event.as_deref() {
|
||||
Some("response.output_item.done") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
completed_items.push(item.clone());
|
||||
}
|
||||
if let Some(call_id) = value.pointer("/item/call_id").and_then(Value::as_str) {
|
||||
function_call_ids.insert(call_id.to_string());
|
||||
}
|
||||
}
|
||||
Some("response.completed") => {
|
||||
response_id = value
|
||||
.pointer("/response/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
collect_response_function_call_ids(&value, &mut function_call_ids);
|
||||
if let Some(completed_output) =
|
||||
value.pointer("/response/output").and_then(Value::as_array)
|
||||
{
|
||||
completed_output.clone_into(&mut output);
|
||||
}
|
||||
if output.is_empty() {
|
||||
output.clone_from(&completed_items);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response_id.map(|response_id| CompletedResponseMetadata {
|
||||
response_id,
|
||||
function_call_ids: function_call_ids.into_iter().collect(),
|
||||
output,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 对单个 Responses SSE 事件的数据执行 tuple schema 回转换。
|
||||
pub fn reconvert_responses_sse_event_tuple_values(
|
||||
event_name: Option<&str>,
|
||||
mut data: Value,
|
||||
tuple_schema: &Value,
|
||||
) -> Value {
|
||||
match responses_event_type(event_name, Some(&data)) {
|
||||
Some("response.output_text.delta") => {
|
||||
reconvert_output_text_delta_tuple_values(&mut data, tuple_schema);
|
||||
}
|
||||
Some("response.output_item.done") => {
|
||||
if let Some(item) = data.get_mut("item") {
|
||||
reconvert_output_item_tuple_values(item, tuple_schema);
|
||||
}
|
||||
}
|
||||
Some("response.completed" | "response.incomplete") => {
|
||||
if let Some(response) = data.get_mut("response") {
|
||||
reconvert_completed_response_tuple_values(response, Some(tuple_schema));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// 判断 Responses SSE 事件是否为终止事件。
|
||||
pub fn response_sse_event_is_terminal(event: &SseEvent) -> bool {
|
||||
let value = serde_json::from_str::<Value>(&event.data).ok();
|
||||
matches!(
|
||||
responses_event_type(event.event.as_deref(), value.as_ref()),
|
||||
Some("response.completed" | "response.incomplete" | "response.failed" | "error")
|
||||
)
|
||||
}
|
||||
|
||||
fn responses_event_type<'a>(
|
||||
event_name: Option<&'a str>,
|
||||
data: Option<&'a Value>,
|
||||
) -> Option<&'a str> {
|
||||
event_name.or_else(|| {
|
||||
data.and_then(|data| data.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
}
|
||||
|
||||
fn reconvert_output_text_delta_tuple_values(data: &mut Value, tuple_schema: &Value) {
|
||||
let Some(delta) = data.get("delta").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(delta) else {
|
||||
return;
|
||||
};
|
||||
let reconverted = reconvert_tuple_values(parsed, tuple_schema);
|
||||
data["delta"] = Value::String(reconverted.to_string());
|
||||
}
|
||||
|
||||
fn reconvert_completed_response_tuple_values(response: &mut Value, tuple_schema: Option<&Value>) {
|
||||
let Some(tuple_schema) = tuple_schema else {
|
||||
return;
|
||||
};
|
||||
let Some(items) = response.get_mut("output").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for item in items {
|
||||
reconvert_output_item_tuple_values(item, tuple_schema);
|
||||
}
|
||||
}
|
||||
|
||||
fn reconvert_output_item_tuple_values(item: &mut Value, tuple_schema: &Value) {
|
||||
let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for part in content {
|
||||
let part_type = part.get("type").and_then(Value::as_str);
|
||||
if !matches!(part_type, Some("output_text" | "text")) {
|
||||
continue;
|
||||
}
|
||||
let Some(text) = part.get("text").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(text) else {
|
||||
continue;
|
||||
};
|
||||
let reconverted = reconvert_tuple_values(parsed, tuple_schema);
|
||||
part["text"] = Value::String(reconverted.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_response_function_call_ids(value: &Value, function_call_ids: &mut BTreeSet<String>) {
|
||||
let Some(output) = value.pointer("/response/output").and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
for item in output {
|
||||
if let Some(call_id) = item.get("call_id").and_then(Value::as_str) {
|
||||
function_call_ids.insert(call_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_message(value: &Value) -> Option<String> {
|
||||
value
|
||||
.pointer("/response/error/message")
|
||||
@@ -632,7 +467,6 @@ impl CodexResponsesRequest {
|
||||
pub fn from_body(body: Map<String, Value>) -> Self {
|
||||
Self {
|
||||
body,
|
||||
tuple_schema: None,
|
||||
explicit_prompt_cache_key: false,
|
||||
client_conversation_id: None,
|
||||
client_session_id: None,
|
||||
@@ -679,12 +513,6 @@ impl CodexResponsesRequest {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 设置模型名(模型后缀路由归一)。
|
||||
pub fn set_model(&mut self, model: impl Into<String>) {
|
||||
self.body
|
||||
.insert("model".to_string(), Value::String(model.into()));
|
||||
}
|
||||
|
||||
/// 指令文本(缺省空串)。
|
||||
pub fn instructions(&self) -> &str {
|
||||
self.body
|
||||
@@ -714,11 +542,6 @@ impl CodexResponsesRequest {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 设置流式标志。
|
||||
pub fn set_stream(&mut self, stream: bool) {
|
||||
self.body.insert("stream".to_string(), Value::Bool(stream));
|
||||
}
|
||||
|
||||
/// 是否要求上游存储响应。
|
||||
pub fn store(&self) -> bool {
|
||||
self.body
|
||||
@@ -847,15 +670,6 @@ impl CodexResponsesRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断请求是否声明了图片生成工具。
|
||||
pub fn expects_image_generation(&self) -> bool {
|
||||
self.tools().is_some_and(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("image_generation"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_owned_string(value: &str) -> Option<String> {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
//! 上游响应元数据提取辅助。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use gateway_protocol::openai::{events::is_rate_limit_header_name, sse::SseEventDecoder};
|
||||
use gateway_protocol::openai::events::is_rate_limit_header_name;
|
||||
use reqwest::header::{HeaderMap, SET_COOKIE};
|
||||
|
||||
use crate::transport::protocol::responses::response_event_signals;
|
||||
|
||||
use super::diagnostics::CodexUpstreamDiagnostics;
|
||||
|
||||
/// Codex Responses 上游响应元数据。
|
||||
@@ -117,41 +113,3 @@ fn upsert_client_header(headers: &mut Vec<(String, String)>, header: (String, St
|
||||
headers.push(header);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_response_timing_ms(
|
||||
started_at: Instant,
|
||||
decoder: &mut SseEventDecoder,
|
||||
chunk: &[u8],
|
||||
first_token_ms: &mut Option<i64>,
|
||||
first_reasoning_ms: &mut Option<i64>,
|
||||
first_text_ms: &mut Option<i64>,
|
||||
) {
|
||||
let Ok(events) = decoder.push(chunk) else {
|
||||
return;
|
||||
};
|
||||
for event in events {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&event.data) else {
|
||||
continue;
|
||||
};
|
||||
let event_type = event.event.as_deref().or_else(|| {
|
||||
value
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
});
|
||||
let signals = response_event_signals(event_type, &value);
|
||||
if !signals.semantic_output {
|
||||
continue;
|
||||
}
|
||||
let elapsed_ms = crate::transport::time::elapsed_millis_i64(started_at).max(1);
|
||||
if first_token_ms.is_none() {
|
||||
*first_token_ms = Some(elapsed_ms);
|
||||
}
|
||||
if signals.reasoning_output && first_reasoning_ms.is_none() {
|
||||
*first_reasoning_ms = Some(elapsed_ms);
|
||||
}
|
||||
if signals.text_output && first_text_ms.is_none() {
|
||||
*first_text_ms = Some(elapsed_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
//! Codex transport 私有时间辅助。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
|
||||
const CHINA_OFFSET_SECONDS: i32 = 8 * 60 * 60;
|
||||
|
||||
pub(crate) fn elapsed_millis_i64(started_at: Instant) -> i64 {
|
||||
started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
pub(crate) fn china_filename_timestamp_millis(value: &DateTime<Utc>) -> String {
|
||||
value
|
||||
.with_timezone(&china_offset())
|
||||
|
||||
@@ -6,9 +6,10 @@ use std::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_FAILURE_THRESHOLD: usize = 3;
|
||||
|
||||
@@ -11,9 +11,7 @@ use super::{
|
||||
},
|
||||
error::CodexWebSocketExchangeError,
|
||||
exchange::{
|
||||
CodexWebSocketExchange, CodexWebSocketStreamingExchange, CollectedWebSocket,
|
||||
WebSocketStreamPoolReturn, WebSocketTerminalKind, collect_websocket_response,
|
||||
reusable_websocket_metadata, stream_websocket_response,
|
||||
CodexWebSocketStreamingExchange, WebSocketStreamPoolReturn, stream_websocket_response,
|
||||
},
|
||||
handshake::{connect_pumped_websocket, send_websocket_request, websocket_connection_metadata},
|
||||
model::{
|
||||
@@ -491,76 +489,6 @@ fn continuation_unavailable(
|
||||
CodexWebSocketExchangeError::ContinuationUnavailable { reason }
|
||||
}
|
||||
|
||||
/// 发送 payload 后只等待该次 exchange;此边界之后不允许 transport fallback。
|
||||
pub(crate) async fn execute_prepared_response_create_request(
|
||||
request: &CodexWebSocketRequest,
|
||||
prepared: PreparedWebSocket,
|
||||
started_at: Instant,
|
||||
) -> Result<CodexWebSocketExchange, CodexWebSocketExchangeError> {
|
||||
let PreparedWebSocket {
|
||||
connection,
|
||||
binding,
|
||||
initial_event_timeout,
|
||||
..
|
||||
} = prepared;
|
||||
let (lease, reused, pool_decision) = binding.into_parts();
|
||||
let PooledWebSocketConnection {
|
||||
websocket,
|
||||
metadata,
|
||||
continuation,
|
||||
created_at,
|
||||
} = connection;
|
||||
if let Err(error) = send_websocket_request(&websocket, request.payload_text()).await {
|
||||
discard_after_send(websocket, lease).await;
|
||||
return Err(post_send_ambiguous(error));
|
||||
}
|
||||
let connection_local_available = lease.is_some();
|
||||
let collected = collect_websocket_response(
|
||||
websocket,
|
||||
metadata,
|
||||
continuation,
|
||||
reused,
|
||||
started_at,
|
||||
initial_event_timeout,
|
||||
)
|
||||
.await;
|
||||
let CollectedWebSocket {
|
||||
mut exchange,
|
||||
websocket,
|
||||
metadata,
|
||||
continuation,
|
||||
terminal,
|
||||
} = match collected {
|
||||
Ok(collected) => collected,
|
||||
Err(error) => {
|
||||
if let Some(lease) = lease {
|
||||
lease.discard().await;
|
||||
}
|
||||
return Err(post_send_ambiguous(error));
|
||||
}
|
||||
};
|
||||
exchange.pool_decision = pool_decision;
|
||||
exchange.connection_local_continuation = connection_local_available;
|
||||
match (terminal, lease) {
|
||||
(WebSocketTerminalKind::Completed, Some(lease)) => {
|
||||
lease
|
||||
.put(PooledWebSocketConnection {
|
||||
websocket,
|
||||
metadata: reusable_websocket_metadata(metadata),
|
||||
continuation,
|
||||
created_at,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
(_, Some(lease)) => {
|
||||
lease.discard().await;
|
||||
websocket.close().await;
|
||||
}
|
||||
(_, None) => websocket.close().await,
|
||||
}
|
||||
Ok(exchange)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_prepared_response_create_request_stream(
|
||||
request: &CodexWebSocketRequest,
|
||||
prepared: PreparedWebSocket,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//! 聚合式 WebSocket exchange。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gateway_protocol::openai::{events, sse::SseEventDecoder};
|
||||
use tungstenite::Message;
|
||||
|
||||
use crate::transport::response_meta;
|
||||
|
||||
use super::super::{
|
||||
pool::{CodexWebSocketConnectionMetadata, WebSocketContinuationState},
|
||||
pump::PumpedWebSocket,
|
||||
};
|
||||
use super::io::{
|
||||
next_websocket_message, receive_idle_timeout, reused_connection_died_before_first_event,
|
||||
};
|
||||
use super::reducer::{ExchangeAction, WebSocketTerminalKind, reduce_websocket_event};
|
||||
use super::{CodexWebSocketExchange, CodexWebSocketExchangeError};
|
||||
|
||||
pub(in crate::transport::websocket) struct CollectedWebSocket {
|
||||
pub(in crate::transport::websocket) exchange: CodexWebSocketExchange,
|
||||
pub(in crate::transport::websocket) websocket: PumpedWebSocket,
|
||||
pub(in crate::transport::websocket) metadata: CodexWebSocketConnectionMetadata,
|
||||
pub(in crate::transport::websocket) continuation: WebSocketContinuationState,
|
||||
pub(in crate::transport::websocket) terminal: WebSocketTerminalKind,
|
||||
}
|
||||
|
||||
pub(in crate::transport::websocket) async fn collect_websocket_response(
|
||||
mut websocket: PumpedWebSocket,
|
||||
mut metadata: CodexWebSocketConnectionMetadata,
|
||||
mut continuation: WebSocketContinuationState,
|
||||
reused_connection: bool,
|
||||
started_at: Instant,
|
||||
initial_event_timeout: Option<Duration>,
|
||||
) -> Result<CollectedWebSocket, CodexWebSocketExchangeError> {
|
||||
let mut body = String::new();
|
||||
let mut saw_upstream_activity = false;
|
||||
let mut first_token_ms = None;
|
||||
let mut first_reasoning_ms = None;
|
||||
let mut first_text_ms = None;
|
||||
let mut first_event_ms = None;
|
||||
let mut output_decoder = SseEventDecoder::default();
|
||||
|
||||
loop {
|
||||
let receive_timeout = receive_idle_timeout(saw_upstream_activity, initial_event_timeout);
|
||||
let message = match next_websocket_message(&mut websocket, receive_timeout).await {
|
||||
Ok(message) => message,
|
||||
Err(CodexWebSocketExchangeError::ReceiveIdleTimeout { timeout })
|
||||
if !saw_upstream_activity =>
|
||||
{
|
||||
if reused_connection {
|
||||
return Err(reused_connection_died_before_first_event(
|
||||
&CodexWebSocketExchangeError::InitialEventTimeout { timeout },
|
||||
));
|
||||
}
|
||||
return Err(CodexWebSocketExchangeError::InitialEventTimeout { timeout });
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some(message) = message else {
|
||||
break;
|
||||
};
|
||||
let text = match message {
|
||||
Message::Text(text) => {
|
||||
saw_upstream_activity = true;
|
||||
first_event_ms.get_or_insert_with(|| {
|
||||
crate::transport::time::elapsed_millis_i64(started_at).max(1)
|
||||
});
|
||||
text
|
||||
}
|
||||
Message::Binary(_) => return Err(CodexWebSocketExchangeError::UnexpectedBinaryEvent),
|
||||
Message::Close(_) if reused_connection && !saw_upstream_activity => {
|
||||
return Err(
|
||||
CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent {
|
||||
message: "websocket closed".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
let raw = text.to_string();
|
||||
let ExchangeAction::Forward { frame, terminal } =
|
||||
reduce_websocket_event(&raw, &mut metadata, &mut continuation)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
body.push_str(&frame);
|
||||
response_meta::update_response_timing_ms(
|
||||
started_at,
|
||||
&mut output_decoder,
|
||||
frame.as_bytes(),
|
||||
&mut first_token_ms,
|
||||
&mut first_reasoning_ms,
|
||||
&mut first_text_ms,
|
||||
);
|
||||
if let Some(terminal) = terminal {
|
||||
let usage = events::extract_sse_usage(&body)?;
|
||||
let exchange = CodexWebSocketExchange {
|
||||
body,
|
||||
usage,
|
||||
turn_state: metadata.turn_state.clone(),
|
||||
set_cookie_headers: metadata.set_cookie_headers.clone(),
|
||||
rate_limit_headers: metadata.rate_limit_headers.clone(),
|
||||
first_token_ms,
|
||||
first_reasoning_ms,
|
||||
first_text_ms,
|
||||
first_event_ms,
|
||||
pool_decision: None,
|
||||
connection_local_continuation: false,
|
||||
diagnostics: metadata.diagnostics.clone(),
|
||||
response_metadata: metadata.response_metadata.clone(),
|
||||
};
|
||||
return Ok(CollectedWebSocket {
|
||||
exchange,
|
||||
websocket,
|
||||
metadata,
|
||||
continuation,
|
||||
terminal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if reused_connection && !saw_upstream_activity {
|
||||
return Err(
|
||||
CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent {
|
||||
message: "websocket closed before terminal event".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Err(CodexWebSocketExchangeError::ClosedBeforeTerminal)
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
//! WebSocket 帧交换、流转发与结果类型。
|
||||
|
||||
mod collect;
|
||||
mod io;
|
||||
mod reducer;
|
||||
mod stream;
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use gateway_protocol::openai::events::TokenUsage;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -21,54 +15,14 @@ use crate::transport::{
|
||||
diagnostics::CodexUpstreamDiagnostics, response_meta::CodexResponseMetadata,
|
||||
};
|
||||
|
||||
use super::breaker::WebSocketOriginBreaker;
|
||||
use super::error::CodexWebSocketExchangeError;
|
||||
use super::pool::{CodexWebSocketConnectionMetadata, WebSocketPoolDecision};
|
||||
use super::{
|
||||
CodexWebSocketRequest, execute_prepared_response_create_request,
|
||||
prepare_response_create_request_with_pool,
|
||||
};
|
||||
|
||||
const WEBSOCKET_RECEIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const WEBSOCKET_ACTIVE_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
const WEBSOCKET_STREAM_BUFFER: usize = 16;
|
||||
|
||||
pub(super) use self::{
|
||||
collect::{CollectedWebSocket, collect_websocket_response},
|
||||
reducer::WebSocketTerminalKind,
|
||||
stream::{WebSocketStreamPoolReturn, stream_websocket_response},
|
||||
};
|
||||
|
||||
/// Responses WebSocket exchange result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWebSocketExchange {
|
||||
/// 完整 SSE 文本。
|
||||
pub body: String,
|
||||
/// 从 SSE 中提取的 usage。
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// 上游 metadata 帧中的最新 turn state。
|
||||
pub turn_state: Option<String>,
|
||||
/// 上游握手响应里的 `set-cookie` 列表。
|
||||
pub set_cookie_headers: Vec<String>,
|
||||
/// 上游握手响应里的限流头。
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// 首个有效上游 WebSocket 事件到达代理的耗时。
|
||||
pub first_token_ms: Option<i64>,
|
||||
/// 首个 reasoning 输出事件到达代理的耗时。
|
||||
pub first_reasoning_ms: Option<i64>,
|
||||
/// 首个正文输出事件到达代理的耗时。
|
||||
pub first_text_ms: Option<i64>,
|
||||
/// 首个上游协议事件到达代理的耗时。
|
||||
pub first_event_ms: Option<i64>,
|
||||
/// WebSocket 连接池决策。
|
||||
pub pool_decision: Option<WebSocketPoolDecision>,
|
||||
/// terminal completed 后该 socket 是否会保留 connection-local continuation。
|
||||
pub connection_local_continuation: bool,
|
||||
/// 上游诊断元数据。
|
||||
pub diagnostics: CodexUpstreamDiagnostics,
|
||||
/// 安全响应元数据。
|
||||
pub response_metadata: CodexResponseMetadata,
|
||||
}
|
||||
pub(super) use self::stream::{WebSocketStreamPoolReturn, stream_websocket_response};
|
||||
|
||||
/// Responses WebSocket live SSE exchange result.
|
||||
pub struct CodexWebSocketStreamingExchange {
|
||||
@@ -104,24 +58,6 @@ pub type CodexWebSocketRateLimitHeaderUpdates = Arc<Mutex<Vec<(String, String)>>
|
||||
/// WebSocket live stream turn-state update.
|
||||
pub type CodexWebSocketTurnStateUpdate = Arc<Mutex<Option<String>>>;
|
||||
|
||||
/// 执行一次 prepared Responses WebSocket 请求并聚合为 SSE。
|
||||
pub async fn execute_response_create_request(
|
||||
request: &CodexWebSocketRequest,
|
||||
) -> Result<CodexWebSocketExchange, CodexWebSocketExchangeError> {
|
||||
let breaker = WebSocketOriginBreaker::default();
|
||||
let prepared = prepare_response_create_request_with_pool(
|
||||
request,
|
||||
None,
|
||||
&breaker,
|
||||
request.connection().endpoint(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
execute_prepared_response_create_request(request, prepared, Instant::now()).await
|
||||
}
|
||||
|
||||
pub(super) fn reusable_websocket_metadata(
|
||||
mut metadata: CodexWebSocketConnectionMetadata,
|
||||
) -> CodexWebSocketConnectionMetadata {
|
||||
|
||||
@@ -21,9 +21,8 @@ pub use self::{
|
||||
},
|
||||
error::{CodexWebSocketExchangeError, CodexWebSocketUpstreamError},
|
||||
exchange::{
|
||||
CodexWebSocketExchange, CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream,
|
||||
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream,
|
||||
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate,
|
||||
execute_response_create_request,
|
||||
},
|
||||
handshake::responses_websocket_endpoint,
|
||||
model::{
|
||||
@@ -37,7 +36,7 @@ pub use self::{
|
||||
};
|
||||
pub(crate) use self::{
|
||||
coordinator::{
|
||||
PreparedWebSocket, WEBSOCKET_FAST_PATH_BUDGET, execute_prepared_response_create_request,
|
||||
PreparedWebSocket, WEBSOCKET_FAST_PATH_BUDGET,
|
||||
execute_prepared_response_create_request_stream, post_send_ambiguous,
|
||||
prepare_response_create_request_with_pool,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::{sync::Semaphore, time::Instant};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
use uuid::Uuid;
|
||||
@@ -393,8 +392,8 @@ impl WebSocketPoolDecision {
|
||||
self.kind.as_str()
|
||||
}
|
||||
|
||||
pub fn metadata_value(self) -> Value {
|
||||
json!({ "kind": self.kind() })
|
||||
pub const fn is_reuse(self) -> bool {
|
||||
matches!(self.kind, WebSocketPoolDecisionKind::Reuse)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ use provider_openai::credential::{
|
||||
CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec,
|
||||
CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError,
|
||||
CodexJwtIdentityVerifier, ExportManagedCodexCredential, ImportCodexOAuthCredential,
|
||||
ImportCodexOAuthCredentialBatch, ReqwestCodexAuthenticatedAccountSource,
|
||||
ReqwestOpenAiJwksSource, RotateManagedCodexCredential,
|
||||
ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, RotateManagedCodexCredential,
|
||||
};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -62,31 +61,6 @@ fn prepare_import_returns_core_account_with_plaintext_json() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_batch_rejects_duplicate_ids_before_store_mutation() {
|
||||
let error = CodexCredentialAdmin
|
||||
.prepare_import_batch(ImportCodexOAuthCredentialBatch {
|
||||
items: vec![
|
||||
import("acct_duplicate", "first"),
|
||||
import("acct_duplicate", "second"),
|
||||
],
|
||||
})
|
||||
.expect_err("duplicate IDs must fail");
|
||||
assert_eq!(error, CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_batch_is_all_or_nothing_validation() {
|
||||
let mut invalid = import("acct_invalid", "second");
|
||||
invalid.provider_instance_id.clear();
|
||||
let error = CodexCredentialAdmin
|
||||
.prepare_import_batch(ImportCodexOAuthCredentialBatch {
|
||||
items: vec![import("acct_valid", "first"), invalid],
|
||||
})
|
||||
.expect_err("invalid item must reject the whole prepared batch");
|
||||
assert_eq!(error, CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rotation_preserves_provider_owned_cookie_data() {
|
||||
let account = codex_account("acct_rotate");
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime};
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_core::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, NativeContinuationReuse, PreviousResponseId,
|
||||
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, ProviderAccountId, ProviderAccountStore,
|
||||
@@ -1714,7 +1714,6 @@ async fn native_continuation_sends_upstream_handle_never_gateway_client_id() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
instance_id(),
|
||||
ProviderAccountId::new("acct_provider").expect("account id"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let second_operation = identity_tainted_operation();
|
||||
let mut second = provider
|
||||
@@ -1810,7 +1809,6 @@ async fn connection_replay_restores_full_input_without_reusing_native_handle() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
instance_id(),
|
||||
ProviderAccountId::new("acct_provider").expect("account id"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let second_operation = identity_tainted_operation()
|
||||
.with_provider_session_state(provider_state.expect("connection replay state"));
|
||||
|
||||
@@ -81,6 +81,29 @@ fn decoder_should_normalize_text_usage_and_completion() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_usage\",\"model\":\"gpt-test\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_usage\",\"model\":\"gpt-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"total_tokens\":17},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n",
|
||||
);
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.push(body.as_bytes())
|
||||
.expect("canonical image usage response");
|
||||
let image_usage = canonical_facts(&events)
|
||||
.into_iter()
|
||||
.find_map(|event| match event {
|
||||
GatewayEvent::Usage(usage) => {
|
||||
Some((usage.image_input_tokens, usage.image_output_tokens))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(image_usage, Some((Some(31), Some(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_accept_official_codex_metadata_lifecycle_events() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -27,7 +27,45 @@ fn explicit_websocket_warmup_request(conversation_id: &str) -> CodexResponsesReq
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_websocket_openings(listener: &TcpListener, attempts: usize) {
|
||||
for _ in 0..attempts {
|
||||
let (mut opening, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut opening).await;
|
||||
assert!(request.starts_with("GET /codex/responses HTTP/1.1"));
|
||||
opening
|
||||
.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_websocket_origin_failures(
|
||||
backend: &CodexBackendClient,
|
||||
attempts: usize,
|
||||
scenario: &str,
|
||||
) {
|
||||
for attempt in 0..attempts {
|
||||
let conversation_id = format!("conversation-{scenario}-{attempt}");
|
||||
let request_id = format!("req_{scenario}_{attempt}");
|
||||
let error = backend
|
||||
.create_response(
|
||||
&explicit_websocket_warmup_request(&conversation_id),
|
||||
request_context(&request_id, Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect_err("seed WebSocket opening should fail");
|
||||
match error {
|
||||
CodexClientError::Upstream { status, .. }
|
||||
if status == reqwest::StatusCode::SERVICE_UNAVAILABLE => {}
|
||||
error => panic!("{scenario} opening {attempt} failed unexpectedly: {error}"),
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cold_websocket_should_fall_back_without_recording_a_successful_connect() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -47,8 +85,7 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(30));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
|
||||
let response = backend
|
||||
.create_response(
|
||||
@@ -70,7 +107,7 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_request() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -78,7 +115,7 @@ async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_requ
|
||||
let server = tokio::spawn(async move {
|
||||
let (websocket_stream, _) = listener.accept().await.unwrap();
|
||||
let websocket_server = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let mut websocket = accept_codex_test_websocket(websocket_stream).await;
|
||||
websocket_ready_tx.send(()).unwrap();
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
@@ -102,8 +139,7 @@ async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_requ
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(30));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let request = new_chain_request("conversation-background-ready");
|
||||
|
||||
let first = backend
|
||||
@@ -157,8 +193,7 @@ async fn shared_websocket_opening_should_keep_the_original_fast_path_deadline()
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(200));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let request = new_chain_request("conversation-original-deadline");
|
||||
|
||||
let first = backend
|
||||
@@ -168,20 +203,23 @@ async fn shared_websocket_opening_should_keep_the_original_fast_path_deadline()
|
||||
)
|
||||
.await
|
||||
.expect("first request should use HTTP after the opening budget");
|
||||
let second = timeout(
|
||||
Duration::from_millis(100),
|
||||
backend.create_response(
|
||||
let second_started_at = tokio::time::Instant::now();
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_original_deadline_second", Some("chatgpt-account")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("shared opening must not grant the second request another full budget")
|
||||
.expect("second request should use HTTP");
|
||||
)
|
||||
.await
|
||||
.expect("second request should use HTTP");
|
||||
let second_elapsed = second_started_at.elapsed();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(first.transport, CodexBackendTransport::HttpSse);
|
||||
assert_eq!(second.transport, CodexBackendTransport::HttpSse);
|
||||
assert!(
|
||||
second_elapsed < Duration::from_millis(400),
|
||||
"shared opening waited for a new budget: {second_elapsed:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.transport_metrics.decision,
|
||||
Some(CodexTransportDecision::Http2WebSocketBudgetExhausted)
|
||||
@@ -204,10 +242,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
|
||||
write_completed_sse_response(&mut http).await;
|
||||
|
||||
let mut byte = [0_u8; 1];
|
||||
let read = timeout(Duration::from_secs(1), stale_opening.read(&mut byte))
|
||||
.await
|
||||
.expect("account eviction should close the opening socket")
|
||||
.unwrap();
|
||||
let read = stale_opening.read(&mut byte).await.unwrap();
|
||||
assert_eq!(read, 0);
|
||||
|
||||
let (fresh_stream, _) = listener.accept().await.unwrap();
|
||||
@@ -226,8 +261,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(30));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let request = new_chain_request("conversation-evict-opening");
|
||||
|
||||
let first = backend
|
||||
@@ -238,33 +272,23 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
|
||||
.await
|
||||
.expect("first request should use HTTP after the opening budget");
|
||||
pool.evict_account("chatgpt-account").await;
|
||||
let second = timeout(
|
||||
Duration::from_secs(2),
|
||||
backend
|
||||
.clone()
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(500))
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_evict_opening_second", Some("chatgpt-account")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("fresh websocket request should finish")
|
||||
.expect("the next request should build a fresh websocket");
|
||||
timeout(Duration::from_secs(2), server)
|
||||
let second = backend
|
||||
.clone()
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_evict_opening_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("test server should finish")
|
||||
.unwrap();
|
||||
.expect("the next request should build a fresh websocket");
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(first.transport, CodexBackendTransport::HttpSse);
|
||||
assert_eq!(second.transport, CodexBackendTransport::WebSocket);
|
||||
assert!(second.body.contains("resp_after_eviction"));
|
||||
timeout(Duration::from_secs(2), pool.shutdown())
|
||||
.await
|
||||
.expect("pool shutdown should finish");
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -291,8 +315,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(30));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
|
||||
backend
|
||||
.create_response(
|
||||
@@ -851,10 +874,7 @@ async fn concurrent_same_key_should_singleflight_websocket_opening() {
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
|
||||
// 该用例验证 same-key singleflight,不验证 200ms deadline。并行全套测试下
|
||||
// 调度停顿可能让首请求也越过 200ms,制造两个 HTTP fallback 的假失败。
|
||||
.with_websocket_fast_path_budget(Duration::from_secs(2));
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
|
||||
let request = new_chain_request("conversation-singleflight");
|
||||
let first_backend = backend.clone();
|
||||
let first_request = request.clone();
|
||||
@@ -970,19 +990,12 @@ async fn origin_breaker_should_open_then_allow_only_one_half_open_probe() {
|
||||
async fn cancelled_half_open_opening_should_allow_another_probe() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let origin_key = format!("http://{addr}");
|
||||
let breaker = WebSocketOriginBreaker::with_config(WebSocketOriginBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
failure_window: Duration::from_secs(1),
|
||||
open_duration: Duration::ZERO,
|
||||
});
|
||||
let WebSocketOriginBreakerDecision::Allowed(permit) = breaker.try_acquire(&origin_key) else {
|
||||
panic!("closed breaker should grant the initial permit");
|
||||
};
|
||||
permit.fast_timeout();
|
||||
let breaker_config = WebSocketOriginBreakerConfig::default();
|
||||
|
||||
let (opening_started_tx, opening_started_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
reject_websocket_openings(&listener, breaker_config.failure_threshold).await;
|
||||
|
||||
let (mut opening, _) = listener.accept().await.unwrap();
|
||||
let request = read_http_request(&mut opening).await;
|
||||
assert!(request.starts_with("GET /codex/responses HTTP/1.1"));
|
||||
@@ -994,17 +1007,37 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
|
||||
.expect("account eviction should close the half-open probe")
|
||||
.unwrap();
|
||||
assert_eq!(read, 0);
|
||||
|
||||
let (fresh_stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(fresh_stream).await;
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_after_cancelled_probe", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
origin_key.clone(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_origin_breaker(breaker.clone());
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
record_websocket_origin_failures(
|
||||
&backend,
|
||||
breaker_config.failure_threshold,
|
||||
"cancelled-half-open-seed",
|
||||
)
|
||||
.await;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(breaker_config.open_duration).await;
|
||||
tokio::time::resume();
|
||||
|
||||
let probe_backend = backend.clone();
|
||||
let attempt = tokio::spawn(async move {
|
||||
backend
|
||||
probe_backend
|
||||
.create_response(
|
||||
&explicit_websocket_warmup_request("conversation-cancelled-half-open"),
|
||||
request_context("req_cancelled_half_open", Some("chatgpt-account")),
|
||||
@@ -1019,17 +1052,18 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
|
||||
.expect("cancelled request should finish")
|
||||
.unwrap()
|
||||
.expect_err("cancelled half-open opening should fail the request");
|
||||
|
||||
let response = backend
|
||||
.create_response(
|
||||
&explicit_websocket_warmup_request("conversation-next-half-open-probe"),
|
||||
request_context("req_next_half_open_probe", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("cancelled half-open opening should allow another probe");
|
||||
server.await.unwrap();
|
||||
|
||||
let WebSocketOriginBreakerDecision::Allowed(next_probe) = breaker.try_acquire(&origin_key)
|
||||
else {
|
||||
panic!("cancelled half-open probe should release its ownership");
|
||||
};
|
||||
assert!(matches!(
|
||||
breaker.try_acquire(&origin_key),
|
||||
WebSocketOriginBreakerDecision::HalfOpenBusy
|
||||
));
|
||||
next_probe.succeed();
|
||||
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
|
||||
assert!(response.body.contains("resp_after_cancelled_probe"));
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -1057,14 +1091,19 @@ fn origin_breaker_should_count_hard_opening_failures() {
|
||||
async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let breaker_config = WebSocketOriginBreakerConfig::default();
|
||||
assert!(breaker_config.failure_threshold >= 3);
|
||||
let seed_failures = breaker_config.failure_threshold - 2;
|
||||
let (late_failure_tx, late_failure_rx) = tokio::sync::oneshot::channel();
|
||||
let (second_used_websocket_tx, second_used_websocket_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
reject_websocket_openings(&listener, seed_failures).await;
|
||||
|
||||
let (mut delayed_opening, _) = listener.accept().await.unwrap();
|
||||
let opening = read_http_request(&mut delayed_opening).await;
|
||||
assert!(opening.starts_with("GET /codex/responses HTTP/1.1"));
|
||||
let delayed_failure = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
delayed_opening
|
||||
.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
@@ -1114,20 +1153,14 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
|
||||
write_completed_sse_response(&mut second_http).await;
|
||||
}
|
||||
});
|
||||
let breaker = WebSocketOriginBreaker::with_config(WebSocketOriginBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
failure_window: Duration::from_secs(1),
|
||||
open_duration: Duration::from_secs(1),
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(30))
|
||||
.with_websocket_origin_breaker(breaker);
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
record_websocket_origin_failures(&backend, seed_failures, "late-failure-seed").await;
|
||||
|
||||
let first = backend
|
||||
.create_response(
|
||||
@@ -1136,58 +1169,34 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
|
||||
)
|
||||
.await
|
||||
.expect("first request should use HTTP after the opening budget");
|
||||
timeout(Duration::from_secs(2), late_failure_rx)
|
||||
.await
|
||||
.expect("delayed opening should receive its 503 response")
|
||||
.unwrap();
|
||||
late_failure_rx.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let second = timeout(
|
||||
Duration::from_secs(2),
|
||||
backend
|
||||
.clone()
|
||||
.with_websocket_fast_path_budget(Duration::from_millis(500))
|
||||
.create_response(
|
||||
&new_chain_request("conversation-late-failure-second"),
|
||||
request_context("req_late_failure_second", Some("chatgpt-account")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("second request should finish")
|
||||
.expect("one degraded opening must not open a threshold-two breaker");
|
||||
let second_used_websocket = timeout(Duration::from_secs(2), second_used_websocket_rx)
|
||||
let second = backend
|
||||
.create_response(
|
||||
&new_chain_request("conversation-late-failure-second"),
|
||||
request_context("req_late_failure_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("test server should observe the second transport")
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), server)
|
||||
.await
|
||||
.expect("test server should finish")
|
||||
.unwrap();
|
||||
.expect("one degraded opening must not open the default breaker");
|
||||
let second_used_websocket = second_used_websocket_rx.await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(first.transport, CodexBackendTransport::HttpSse);
|
||||
assert!(second_used_websocket);
|
||||
assert_eq!(second.transport, CodexBackendTransport::WebSocket);
|
||||
assert!(second.body.contains("resp_after_late_failure"));
|
||||
timeout(Duration::from_secs(2), pool.shutdown())
|
||||
.await
|
||||
.expect("pool shutdown should finish");
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn half_open_upstream_response_should_close_origin_breaker() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let origin_key = format!("http://{addr}");
|
||||
let breaker = WebSocketOriginBreaker::with_config(WebSocketOriginBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
failure_window: Duration::from_secs(1),
|
||||
open_duration: Duration::ZERO,
|
||||
});
|
||||
let WebSocketOriginBreakerDecision::Allowed(permit) = breaker.try_acquire(&origin_key) else {
|
||||
panic!("closed breaker should grant the initial permit");
|
||||
};
|
||||
permit.fast_timeout();
|
||||
let breaker_config = WebSocketOriginBreakerConfig::default();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
reject_websocket_openings(&listener, breaker_config.failure_threshold).await;
|
||||
|
||||
let (mut probe, _) = listener.accept().await.unwrap();
|
||||
let opening = read_http_request(&mut probe).await;
|
||||
assert!(opening.starts_with("GET /codex/responses HTTP/1.1"));
|
||||
@@ -1197,15 +1206,33 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (next_stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(next_stream).await;
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_after_half_open_success", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
origin_key.clone(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
|
||||
.with_websocket_fast_path_budget(Duration::from_secs(1))
|
||||
.with_websocket_origin_breaker(breaker.clone());
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
record_websocket_origin_failures(
|
||||
&backend,
|
||||
breaker_config.failure_threshold,
|
||||
"half-open-response-seed",
|
||||
)
|
||||
.await;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(breaker_config.open_duration).await;
|
||||
tokio::time::resume();
|
||||
|
||||
let error = backend
|
||||
.create_response(
|
||||
@@ -1214,7 +1241,6 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
|
||||
)
|
||||
.await
|
||||
.expect_err("half-open account response should remain explicit");
|
||||
server.await.unwrap();
|
||||
|
||||
std::assert_matches!(
|
||||
error,
|
||||
@@ -1224,8 +1250,16 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
|
||||
..
|
||||
} if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert!(matches!(
|
||||
breaker.try_acquire(&origin_key),
|
||||
WebSocketOriginBreakerDecision::Allowed(_)
|
||||
));
|
||||
let response = backend
|
||||
.create_response(
|
||||
&explicit_websocket_warmup_request("conversation-after-half-open-response"),
|
||||
request_context("req_after_half_open_response", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("half-open upstream response should close the origin breaker");
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
|
||||
assert!(response.body.contains("resp_after_half_open_success"));
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -4,22 +4,24 @@ use std::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_protocol::openai::events::{TokenUsage, extract_sse_usage};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope,
|
||||
};
|
||||
use provider_openai::transport::websocket::{
|
||||
CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketRequest,
|
||||
execute_response_create_request, responses_websocket_endpoint,
|
||||
responses_websocket_endpoint,
|
||||
};
|
||||
use provider_openai::transport::{
|
||||
CodexBackendClient, CodexBackendTransport, CodexClientError, CodexRequestContext,
|
||||
CodexTransportDecision, CodexWebSocketPool, CodexWebSocketPoolConfig,
|
||||
CodexBackendClient, CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError,
|
||||
CodexClientResult, CodexRequestContext, CodexResponseMetadata, CodexTransportDecision,
|
||||
CodexTransportMetrics, CodexWebSocketPool, CodexWebSocketPoolConfig, WebSocketPoolDecision,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::{
|
||||
@@ -146,6 +148,155 @@ fn request_context<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CollectedBackendResponse {
|
||||
body: String,
|
||||
transport: CodexBackendTransport,
|
||||
usage: Option<TokenUsage>,
|
||||
turn_state: Option<String>,
|
||||
set_cookie_headers: Vec<String>,
|
||||
rate_limit_headers: Vec<(String, String)>,
|
||||
websocket_pool_decision: Option<WebSocketPoolDecision>,
|
||||
response_metadata: CodexResponseMetadata,
|
||||
transport_metrics: CodexTransportMetrics,
|
||||
connection_local_continuation: bool,
|
||||
}
|
||||
|
||||
trait CodexBackendClientTestExt {
|
||||
async fn create_response(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CollectedBackendResponse>;
|
||||
|
||||
async fn create_response_stream(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexBackendStreamingResponse>;
|
||||
|
||||
async fn create_response_with_pool_account_started_at(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
pool_account_id: Option<&str>,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CollectedBackendResponse>;
|
||||
}
|
||||
|
||||
impl CodexBackendClientTestExt for CodexBackendClient {
|
||||
async fn create_response(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CollectedBackendResponse> {
|
||||
let started_at = Instant::now();
|
||||
let response = self
|
||||
.create_response_stream_with_pool_account(request, context, None)
|
||||
.await?;
|
||||
collect_backend_response(response, started_at).await
|
||||
}
|
||||
|
||||
async fn create_response_stream(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<CodexBackendStreamingResponse> {
|
||||
self.create_response_stream_with_pool_account(request, context, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_response_with_pool_account_started_at(
|
||||
&self,
|
||||
request: &CodexResponsesRequest,
|
||||
context: CodexRequestContext<'_>,
|
||||
pool_account_id: Option<&str>,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CollectedBackendResponse> {
|
||||
let response = self
|
||||
.create_response_stream_with_pool_account(request, context, pool_account_id)
|
||||
.await?;
|
||||
collect_backend_response(response, started_at).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_backend_response(
|
||||
response: CodexBackendStreamingResponse,
|
||||
started_at: Instant,
|
||||
) -> CodexClientResult<CollectedBackendResponse> {
|
||||
let CodexBackendStreamingResponse {
|
||||
mut body,
|
||||
transport,
|
||||
mut turn_state,
|
||||
set_cookie_headers,
|
||||
mut rate_limit_headers,
|
||||
rate_limit_header_updates,
|
||||
turn_state_update,
|
||||
websocket_pool_decision,
|
||||
diagnostics: _,
|
||||
response_metadata,
|
||||
mut transport_metrics,
|
||||
connection_local_continuation,
|
||||
} = response;
|
||||
let mut body_bytes = Vec::new();
|
||||
while let Some(chunk) = body.next().await {
|
||||
let chunk = chunk?;
|
||||
transport_metrics.first_event_ms.get_or_insert_with(|| {
|
||||
i64::try_from(started_at.elapsed().as_millis())
|
||||
.unwrap_or(i64::MAX)
|
||||
.max(1)
|
||||
});
|
||||
body_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if let Some(updates) = rate_limit_header_updates {
|
||||
rate_limit_headers.extend(updates.lock().await.iter().cloned());
|
||||
}
|
||||
if let Some(update) = turn_state_update {
|
||||
turn_state = update.lock().await.clone().or(turn_state);
|
||||
}
|
||||
let body = String::from_utf8_lossy(&body_bytes).into_owned();
|
||||
let usage = extract_sse_usage(&body).map_err(CodexClientError::InvalidSse)?;
|
||||
Ok(CollectedBackendResponse {
|
||||
body,
|
||||
transport,
|
||||
usage,
|
||||
turn_state,
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
websocket_pool_decision,
|
||||
response_metadata,
|
||||
transport_metrics,
|
||||
connection_local_continuation,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_response_create_request(
|
||||
request: &CodexWebSocketRequest,
|
||||
) -> CodexClientResult<CollectedBackendResponse> {
|
||||
let endpoint = request.connection().endpoint();
|
||||
let base_url = endpoint
|
||||
.strip_suffix("/codex/responses")
|
||||
.unwrap_or(endpoint)
|
||||
.replacen("ws://", "http://", 1)
|
||||
.replacen("wss://", "https://", 1);
|
||||
let mut body = serde_json::from_str::<Map<String, Value>>(request.payload_text())
|
||||
.expect("test WebSocket payload should be valid JSON");
|
||||
body.remove("type");
|
||||
let mut upstream_request = CodexResponsesRequest::from_body(body);
|
||||
upstream_request.use_websocket = true;
|
||||
let client = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
base_url,
|
||||
test_wire_profile(),
|
||||
);
|
||||
client
|
||||
.create_response(
|
||||
&upstream_request,
|
||||
request_context("req_test_websocket", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn codex_request(
|
||||
model: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
|
||||
@@ -409,12 +409,18 @@ async fn websocket_execute_response_create_request_should_preserve_opening_error
|
||||
.expect_err("failed opening should surface upstream status");
|
||||
server.await.unwrap();
|
||||
|
||||
let CodexWebSocketExchangeError::Upstream(error) = error else {
|
||||
let CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds,
|
||||
body,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
panic!("expected upstream opening error");
|
||||
};
|
||||
assert_eq!(error.status_code, 429);
|
||||
assert_eq!(error.retry_after_seconds, Some(33));
|
||||
assert!(error.body.contains("rate limited"));
|
||||
assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(retry_after_seconds, Some(33));
|
||||
assert!(body.contains("rate limited"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -438,14 +444,14 @@ async fn websocket_execute_response_create_request_should_reject_binary_event()
|
||||
.expect_err("binary websocket events should be rejected");
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::error::Error::source(&error).map(ToString::to_string),
|
||||
Some("unexpected binary websocket event".to_string())
|
||||
);
|
||||
std::assert_matches!(
|
||||
error,
|
||||
CodexWebSocketExchangeError::PostSendAmbiguous { message, .. }
|
||||
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
|
||||
message,
|
||||
source: Some(source),
|
||||
})
|
||||
if message.contains("unexpected binary websocket event")
|
||||
&& matches!(*source, CodexWebSocketExchangeError::UnexpectedBinaryEvent)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -834,7 +840,10 @@ async fn websocket_execute_response_create_request_should_reject_invalid_complet
|
||||
.expect_err("invalid response.completed should be rejected");
|
||||
server.await.unwrap();
|
||||
|
||||
let CodexWebSocketExchangeError::PostSendAmbiguous { message, .. } = error else {
|
||||
let CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
|
||||
message, ..
|
||||
}) = error
|
||||
else {
|
||||
panic!("expected post-send ambiguous websocket response");
|
||||
};
|
||||
assert!(message.contains("failed to parse ResponseCompleted"));
|
||||
@@ -879,7 +888,10 @@ async fn websocket_execute_response_create_request_should_reject_completed_witho
|
||||
|
||||
std::assert_matches!(
|
||||
error,
|
||||
CodexWebSocketExchangeError::PostSendAmbiguous { message, .. }
|
||||
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
|
||||
message,
|
||||
..
|
||||
})
|
||||
if message.contains("response.completed is missing response")
|
||||
);
|
||||
}
|
||||
@@ -931,7 +943,7 @@ async fn websocket_execute_response_create_request_should_return_error_terminal_
|
||||
assert!(response.body.contains("No tool output found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -949,8 +961,7 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
|
||||
);
|
||||
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
|
||||
request.set_previous_response_id(Some("resp_previous".to_string()));
|
||||
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
|
||||
@@ -970,11 +981,11 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
|
||||
message,
|
||||
..
|
||||
})
|
||||
if message.contains("30ms")
|
||||
if message.contains("20s")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[tokio::test]
|
||||
async fn websocket_response_created_should_switch_to_active_stream_idle_timeout() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -1009,8 +1020,9 @@ async fn websocket_response_created_should_switch_to_active_stream_idle_timeout(
|
||||
tokio::spawn(async move { execute_response_create_request(&prepared).await });
|
||||
|
||||
created_rx.await.unwrap();
|
||||
tokio::time::advance(Duration::from_secs(30)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(30)).await;
|
||||
let response = response_task
|
||||
.await
|
||||
.expect("websocket task should finish")
|
||||
@@ -1132,7 +1144,7 @@ async fn codex_backend_client_stream_should_reject_binary_websocket_event() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn codex_backend_client_stream_should_keep_socket_after_structural_activity() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -1158,7 +1170,7 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
first_websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
@@ -1182,8 +1194,7 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
|
||||
);
|
||||
let request = pooled_websocket_request("conversation-structural-no-pool");
|
||||
|
||||
let response = backend
|
||||
|
||||
@@ -1068,8 +1068,7 @@ async fn concurrent_pool_transport_counts(
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool))
|
||||
.with_websocket_fast_path_budget(Duration::from_secs(5));
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let start = Arc::new(tokio::sync::Barrier::new(REQUEST_COUNT + 1));
|
||||
let mut requests = Vec::new();
|
||||
for index in 0..REQUEST_COUNT {
|
||||
|
||||
@@ -228,21 +228,11 @@ impl GrokOAuthClient {
|
||||
discovery: &DiscoveryDocument,
|
||||
grant: &RefreshTokenGrant,
|
||||
) -> Result<RefreshedTokenSet, OAuthError> {
|
||||
let mut form = vec![
|
||||
let form = vec![
|
||||
FormField::public("grant_type", "refresh_token"),
|
||||
FormField::secret("refresh_token", grant.refresh_token().clone()),
|
||||
FormField::public("client_id", self.config.client_id()),
|
||||
];
|
||||
if let Some(principal) = grant.principal() {
|
||||
form.push(FormField::public(
|
||||
"principal_type",
|
||||
principal.principal_type(),
|
||||
));
|
||||
form.push(FormField::secret(
|
||||
"principal_id",
|
||||
crate::SecretValue::new(principal.principal_id().to_owned()),
|
||||
));
|
||||
}
|
||||
let response = self
|
||||
.execute(
|
||||
OAuthOperation::RefreshToken,
|
||||
|
||||
@@ -64,12 +64,6 @@ impl FormValue {
|
||||
Self::Secret(value) => value.expose(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether debug and telemetry layers must redact this value.
|
||||
#[must_use]
|
||||
pub const fn is_secret(&self) -> bool {
|
||||
matches!(self, Self::Secret(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FormValue {
|
||||
|
||||
@@ -479,14 +479,6 @@ impl GrokCredentialRefreshService {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
pub async fn refresh_one(
|
||||
&self,
|
||||
credential: DueGrokCredential,
|
||||
) -> Result<GrokCredentialRefreshOutcome, GrokCredentialRefreshError> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await?;
|
||||
self.refresh_one_with_policy(credential, policy).await
|
||||
}
|
||||
|
||||
async fn refresh_one_with_policy(
|
||||
&self,
|
||||
credential: DueGrokCredential,
|
||||
|
||||
@@ -181,26 +181,6 @@ impl GrokCredentialAdmin {
|
||||
})
|
||||
}
|
||||
|
||||
/// 批量验证 OAuth account;任一输入失败或 ID 重复时整批拒绝。
|
||||
pub fn prepare_import_batch(
|
||||
&self,
|
||||
inputs: &[CreateGrokCredential],
|
||||
) -> Result<Vec<NewProviderAccount>, GrokCredentialRepositoryError> {
|
||||
if inputs.is_empty() || inputs.len() > MAX_IMPORT_BATCH {
|
||||
return Err(GrokCredentialRepositoryError::InvalidInput("batch"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut prepared = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let account = self.prepare_import(input)?;
|
||||
if !ids.insert(account.account.id().clone()) {
|
||||
return Err(GrokCredentialRepositoryError::InvalidInput("account_id"));
|
||||
}
|
||||
prepared.push(account);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// 把已验证 token 的 Provider 生命周期事实一次性投影为 Core 创建命令。
|
||||
pub fn prepare_verified_account(
|
||||
&self,
|
||||
|
||||
@@ -69,33 +69,18 @@ impl OAuthPrincipal {
|
||||
#[derive(Clone)]
|
||||
pub struct RefreshTokenGrant {
|
||||
refresh_token: SecretValue,
|
||||
principal: Option<OAuthPrincipal>,
|
||||
}
|
||||
|
||||
impl RefreshTokenGrant {
|
||||
/// Creates a refresh grant for an existing verified credential.
|
||||
#[must_use]
|
||||
pub fn new(refresh_token: SecretValue) -> Self {
|
||||
Self {
|
||||
refresh_token,
|
||||
principal: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches optional team principal metadata.
|
||||
#[must_use]
|
||||
pub fn with_principal(mut self, principal: OAuthPrincipal) -> Self {
|
||||
self.principal = Some(principal);
|
||||
self
|
||||
Self { refresh_token }
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_token(&self) -> &SecretValue {
|
||||
&self.refresh_token
|
||||
}
|
||||
|
||||
pub(crate) fn principal(&self) -> Option<&OAuthPrincipal> {
|
||||
self.principal.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RefreshTokenGrant {
|
||||
@@ -103,7 +88,6 @@ impl fmt::Debug for RefreshTokenGrant {
|
||||
formatter
|
||||
.debug_struct("RefreshTokenGrant")
|
||||
.field("refresh_token", &"[REDACTED]")
|
||||
.field("principal", &self.principal)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +638,8 @@ fn core_usage(usage: TokenUsage) -> Usage {
|
||||
normalized.cached_tokens = Some(usage.cached_tokens);
|
||||
normalized.cache_write_tokens = Some(usage.cache_write_tokens);
|
||||
normalized.reasoning_tokens = Some(usage.reasoning_tokens);
|
||||
normalized.image_input_tokens = Some(usage.image_input_tokens);
|
||||
normalized.image_output_tokens = Some(usage.image_output_tokens);
|
||||
normalized.total_tokens = Some(usage.total_tokens);
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -53,12 +53,6 @@ impl GrokHeaderValue {
|
||||
Self::Sensitive(value) => value.expose(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether the value must be excluded from logs and diagnostics.
|
||||
#[must_use]
|
||||
pub const fn is_sensitive(&self) -> bool {
|
||||
matches!(self, Self::Sensitive(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrokHeaderValue {
|
||||
|
||||
@@ -177,14 +177,14 @@ impl GrokEndpointPolicy for OfficialGrokEndpointPolicy {
|
||||
&self,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Client, GrokReqwestTransportBuildError> {
|
||||
build_official_client(OFFICIAL_OAUTH_HOST, timeout)
|
||||
build_official_client(GrokDnsResolutionPolicy::official_oauth(), timeout)
|
||||
}
|
||||
|
||||
fn build_inference_client(
|
||||
&self,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Client, GrokReqwestTransportBuildError> {
|
||||
build_official_client(OFFICIAL_INFERENCE_HOST, timeout)
|
||||
build_official_client(GrokDnsResolutionPolicy::official_inference(), timeout)
|
||||
}
|
||||
|
||||
fn validate_oauth(&self, url: &Url) -> bool {
|
||||
@@ -579,7 +579,7 @@ impl GrokBillingTransport for ReqwestGrokModelCatalogTransport {
|
||||
}
|
||||
|
||||
fn build_official_client(
|
||||
host: &'static str,
|
||||
dns_policy: GrokDnsResolutionPolicy,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Client, GrokReqwestTransportBuildError> {
|
||||
let mut builder = Client::builder()
|
||||
@@ -591,7 +591,7 @@ fn build_official_client(
|
||||
.http2_adaptive_window(true)
|
||||
.tcp_nodelay(true)
|
||||
.https_only(true)
|
||||
.dns_resolver(Arc::new(StrictDnsResolver::new(host)?));
|
||||
.dns_resolver(Arc::new(StrictDnsResolver::new(dns_policy)?));
|
||||
if let Some(timeout) = timeout {
|
||||
builder = builder.timeout(timeout);
|
||||
}
|
||||
@@ -633,9 +633,9 @@ struct StrictDnsResolver {
|
||||
}
|
||||
|
||||
impl StrictDnsResolver {
|
||||
fn new(allowed_host: &'static str) -> Result<Self, GrokReqwestTransportBuildError> {
|
||||
fn new(policy: GrokDnsResolutionPolicy) -> Result<Self, GrokReqwestTransportBuildError> {
|
||||
Ok(Self {
|
||||
policy: GrokDnsResolutionPolicy { allowed_host },
|
||||
policy,
|
||||
trusted_doh: TrustedDohResolver::new()?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,33 +168,6 @@ impl GrokResponsesRequest {
|
||||
self.turn_index = None;
|
||||
}
|
||||
|
||||
pub fn strip_reasoning_encrypted_content(&mut self) -> bool {
|
||||
let Some(items) = self.body.get_mut("input").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
let original_len = items.len();
|
||||
let mut changed = false;
|
||||
items.retain_mut(|item| {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
return true;
|
||||
};
|
||||
if string_field(item, "type") != "reasoning" {
|
||||
return true;
|
||||
}
|
||||
for field in ["encrypted_content", "id", "status"] {
|
||||
changed |= item.remove(field).is_some();
|
||||
}
|
||||
let portable = ["summary", "content"].into_iter().any(|field| {
|
||||
item.get(field)
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| !values.is_empty())
|
||||
});
|
||||
changed |= !portable;
|
||||
portable
|
||||
});
|
||||
changed || items.len() != original_len
|
||||
}
|
||||
|
||||
pub fn encode(
|
||||
request: &GenerateRequest,
|
||||
upstream_model: &str,
|
||||
|
||||
@@ -396,39 +396,3 @@ fn oauth_secret_debug_never_exposes_plaintext() {
|
||||
assert!(debug.contains("REDACTED"));
|
||||
assert!(!debug.contains("visible-only-to-provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_batch_preparation_is_all_or_nothing_and_rejects_duplicate_ids() {
|
||||
let valid = [
|
||||
create_input("batch-a", "subject-batch-a"),
|
||||
create_input("batch-b", "subject-batch-b"),
|
||||
];
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_import_batch(&valid)
|
||||
.expect("valid batch");
|
||||
assert_eq!(prepared.len(), 2);
|
||||
|
||||
let duplicate = [
|
||||
create_input("batch-duplicate", "subject-one"),
|
||||
create_input("batch-duplicate", "subject-two"),
|
||||
];
|
||||
assert!(matches!(
|
||||
GrokCredentialAdmin.prepare_import_batch(&duplicate),
|
||||
Err(GrokCredentialRepositoryError::InvalidInput("account_id"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_batch_preparation_enforces_empty_and_upper_boundaries() {
|
||||
assert!(matches!(
|
||||
GrokCredentialAdmin.prepare_import_batch(&[]),
|
||||
Err(GrokCredentialRepositoryError::InvalidInput("batch"))
|
||||
));
|
||||
let oversized = (0..201)
|
||||
.map(|index| create_input(&format!("batch-{index}"), &format!("subject-{index}")))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(matches!(
|
||||
GrokCredentialAdmin.prepare_import_batch(&oversized),
|
||||
Err(GrokCredentialRepositoryError::InvalidInput("batch"))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -12,16 +12,15 @@ use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderStoreError,
|
||||
};
|
||||
use provider_xai::{
|
||||
GrokCredentialAdmin, GrokCredentialCatalogCache, GrokCredentialRecovery,
|
||||
GrokCredentialRecoveryOutcome, GrokCredentialRefreshError, GrokCredentialRefreshOutcome,
|
||||
GrokCredentialRefreshService, GrokCredentialRefresher, GrokCredentialRepository,
|
||||
GrokModelCatalogRequest, GrokModelCatalogTransport, GrokModelCatalogTransportFuture,
|
||||
GrokModelCatalogTransportResponse, GrokRefreshFailure, GrokRefreshTokens,
|
||||
RotateManagedGrokCredential, SecretValue,
|
||||
GrokCredentialCatalogCache, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
|
||||
GrokCredentialRefreshError, GrokCredentialRefreshOutcome, GrokCredentialRefreshService,
|
||||
GrokCredentialRefresher, GrokCredentialRepository, GrokModelCatalogRequest,
|
||||
GrokModelCatalogTransport, GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse,
|
||||
GrokRefreshFailure, GrokRefreshTokens, SecretValue,
|
||||
};
|
||||
|
||||
use crate::support::{
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object, profile,
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object,
|
||||
runtime_policy, seed_input,
|
||||
};
|
||||
|
||||
@@ -420,48 +419,6 @@ async fn pre_send_transient_failure_applies_bounded_cooldown() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_due_snapshot_cannot_overwrite_newer_revision() {
|
||||
let input = due_input("stale");
|
||||
let id = input.account_id.clone();
|
||||
let (store, repository, _, service) =
|
||||
fixture(input, [Err(GrokRefreshFailure::InvalidGrant)], true).await;
|
||||
let due = repository
|
||||
.list_due_refresh()
|
||||
.await
|
||||
.expect("due list")
|
||||
.pop()
|
||||
.expect("due account");
|
||||
let current = store
|
||||
.load_credential(&id, CredentialRevision::new(1).expect("revision"))
|
||||
.await
|
||||
.expect("current credential");
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_rotation(&RotateManagedGrokCredential {
|
||||
current,
|
||||
secret: provider_xai::GrokOAuthSecret {
|
||||
access_token: SecretValue::new("admin-access"),
|
||||
refresh_token: SecretValue::new("admin-refresh"),
|
||||
id_token: None,
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject-stale"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("admin rotation");
|
||||
assert!(matches!(
|
||||
store
|
||||
.compare_and_swap_credential(prepared.credential)
|
||||
.await
|
||||
.expect("persist admin rotation"),
|
||||
CredentialCasOutcome::Updated(revision) if revision.get() == 2
|
||||
));
|
||||
assert!(matches!(
|
||||
service.refresh_one(due).await.expect("stale outcome"),
|
||||
GrokCredentialRefreshOutcome::Stale { account_id } if account_id == id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_refresh_lifetime_is_rejected_without_cas_write() {
|
||||
let input = due_input("bad-lifetime");
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use futures::{StreamExt, stream};
|
||||
use gateway_core::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, NativeContinuationReuse, PreviousResponseId,
|
||||
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, CredentialRevision, ProviderAccountStore, RotationStrategy,
|
||||
@@ -1154,7 +1154,6 @@ async fn native_previous_response_is_rejected_before_selection() {
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let error = match provider
|
||||
.execute(
|
||||
@@ -1189,7 +1188,6 @@ async fn native_previous_response_pins_account_and_sends_upstream_handle() {
|
||||
ProviderKind::new("xai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
@@ -1236,7 +1234,6 @@ async fn native_previous_response_does_not_allow_quota_or_rate_limit_account_rot
|
||||
ProviderKind::new("xai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
@@ -1330,7 +1327,6 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco
|
||||
ProviderKind::new("xai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let continued_operation = operation_with_state(
|
||||
json!({
|
||||
@@ -1457,7 +1453,6 @@ async fn replay_owner_should_reencode_custom_apply_patch_call_for_grok() {
|
||||
ProviderKind::new("xai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let replay_operation = operation_with_state(
|
||||
json!({
|
||||
@@ -1534,7 +1529,6 @@ async fn missing_native_response_should_be_replay_safe_for_the_same_account() {
|
||||
ProviderKind::new("xai").expect("provider"),
|
||||
instance_id(),
|
||||
account_id("provider"),
|
||||
NativeContinuationReuse::Reusable,
|
||||
);
|
||||
let operation = operation_with_state(
|
||||
json!({
|
||||
|
||||
@@ -115,6 +115,23 @@ fn decoder_should_normalize_text_usage_and_completion() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_usage\",\"model\":\"grok-4.5\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_usage\",\"model\":\"grok-4.5\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"total_tokens\":17},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n",
|
||||
);
|
||||
let events = decode_canonical(body.as_bytes()).expect("canonical image usage response");
|
||||
let image_usage = events.iter().find_map(|event| match event {
|
||||
GatewayEvent::Usage(usage) => Some((usage.image_input_tokens, usage.image_output_tokens)),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(image_usage, Some((Some(31), Some(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_leave_cost_unavailable_when_upstream_omits_it() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -244,6 +244,7 @@ create table model_requests (
|
||||
provider_account_ref text,
|
||||
upstream_transport text,
|
||||
http_version text,
|
||||
websocket_pool text,
|
||||
attempt_count integer not null default 0,
|
||||
upstream_send_state text not null default 'not_sent',
|
||||
downstream_committed_at timestamptz,
|
||||
@@ -262,7 +263,10 @@ create table model_requests (
|
||||
cached_tokens bigint,
|
||||
cache_write_tokens bigint,
|
||||
reasoning_tokens bigint,
|
||||
image_input_tokens bigint,
|
||||
image_output_tokens bigint,
|
||||
total_tokens bigint,
|
||||
image_generation_succeeded boolean,
|
||||
cost_source text not null default 'unavailable',
|
||||
cost_amount numeric(20, 10),
|
||||
cost_currency text,
|
||||
@@ -282,6 +286,7 @@ create table model_requests (
|
||||
request_kind text,
|
||||
subagent_kind text,
|
||||
compact boolean not null default false,
|
||||
image_generation_requested boolean not null default false,
|
||||
started_at timestamptz not null,
|
||||
deadline_at timestamptz not null,
|
||||
completed_at timestamptz,
|
||||
@@ -326,8 +331,26 @@ create table model_requests (
|
||||
and (cached_tokens is null or cached_tokens >= 0)
|
||||
and (cache_write_tokens is null or cache_write_tokens >= 0)
|
||||
and (reasoning_tokens is null or reasoning_tokens >= 0)
|
||||
and (image_input_tokens is null or image_input_tokens >= 0)
|
||||
and (image_output_tokens is null or image_output_tokens >= 0)
|
||||
and (total_tokens is null or total_tokens >= 0)
|
||||
),
|
||||
constraint model_requests_image_generation_ck check (
|
||||
(
|
||||
not image_generation_requested
|
||||
and image_generation_succeeded is null
|
||||
)
|
||||
or (
|
||||
image_generation_requested
|
||||
and (
|
||||
(outcome = 'running' and image_generation_succeeded is null)
|
||||
or (outcome <> 'running' and image_generation_succeeded is not null)
|
||||
)
|
||||
)
|
||||
),
|
||||
constraint model_requests_websocket_pool_ck check (
|
||||
websocket_pool is null or websocket_pool in ('new', 'reuse')
|
||||
),
|
||||
constraint model_requests_cost_ck check (
|
||||
cost_source in ('provider_reported', 'calculated', 'unavailable')
|
||||
and (
|
||||
|
||||
Reference in New Issue
Block a user