Merge remote-tracking branch 'upstream/main'

This commit is contained in:
AAEE86
2026-06-16 10:53:47 +08:00
168 changed files with 24971 additions and 1332 deletions
+1
View File
@@ -2,6 +2,7 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=python
*.rsa
*_rsa
# AI Assistant Configuration
.codex/
+7 -3
View File
@@ -23,10 +23,11 @@ RUN npm run build
FROM ${RUST_BASE_IMAGE} AS gateway-base
WORKDIR /build
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_PROFILE_RELEASE_LTO=thin \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -34,10 +35,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
clang \
cmake \
git \
libclang-dev \
libssl-dev \
lld \
pkg-config \
perl
@@ -67,7 +70,8 @@ COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
cargo build --release --locked -p aether-gateway && \
set -eux; \
cargo build --release --locked -p aether-gateway --bin aether-gateway; \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
@@ -172,6 +172,9 @@ fn aggregates_openai_responses_stream_completed_event_to_final_response() {
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -180,6 +183,9 @@ fn aggregates_openai_responses_stream_completed_event_to_final_response() {
"object": "response",
"model": "gpt-5",
"status": "completed",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello",
"output": [{
"type": "message",
"id": "resp_123_msg",
@@ -215,6 +221,9 @@ fn aggregates_openai_responses_stream_tool_call_events_to_final_response() {
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -223,6 +232,9 @@ fn aggregates_openai_responses_stream_tool_call_events_to_final_response() {
"object": "response",
"model": "gpt-5",
"status": "completed",
"created_at": created_at,
"completed_at": created_at,
"output_text": "",
"output": [{
"type": "function_call",
"id": "call_123",
@@ -811,6 +823,9 @@ fn converts_claude_cli_response_to_openai_responses_response() {
}),
)
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -819,6 +834,9 @@ fn converts_claude_cli_response_to_openai_responses_response() {
"object": "response",
"status": "completed",
"model": "claude-code-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello Claude CLI",
"output": [{
"type": "message",
"id": "msg_cli_123_msg",
@@ -868,6 +886,9 @@ fn converts_claude_cli_tool_use_to_openai_responses_function_call() {
}),
)
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -876,6 +897,9 @@ fn converts_claude_cli_tool_use_to_openai_responses_function_call() {
"object": "response",
"status": "completed",
"model": "claude-code-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Running tool.",
"output": [
{
"type": "message",
@@ -933,6 +957,9 @@ fn converts_gemini_cli_response_to_openai_responses_response() {
}),
)
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -941,6 +968,9 @@ fn converts_gemini_cli_response_to_openai_responses_response() {
"object": "response",
"status": "completed",
"model": "gemini-cli-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello Gemini CLI",
"output": [{
"type": "message",
"id": "resp_cli_123_msg",
@@ -995,6 +1025,9 @@ fn converts_gemini_cli_function_call_to_openai_responses_function_call() {
}),
)
.expect("result should exist");
let created_at = result["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
result,
@@ -1003,6 +1036,9 @@ fn converts_gemini_cli_function_call_to_openai_responses_function_call() {
"object": "response",
"status": "completed",
"model": "gemini-cli-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Need a tool.",
"output": [
{
"type": "message",
@@ -719,6 +719,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5","metadata":{}})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -20,6 +20,7 @@ mod pool_scheduler;
pub(crate) mod pool_scores;
mod redaction;
mod report_context;
mod request_gzip;
mod route;
mod runtime_miss;
mod spec_metadata;
@@ -46,6 +47,7 @@ pub(crate) use self::plan_builders::{
pub(crate) use self::pool_scores::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
pub(crate) use self::request_gzip::resolve_transport_request_gzip_policy;
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
pub(crate) use self::runtime_miss::{
apply_local_runtime_candidate_terminal_reason, record_local_runtime_candidate_skip_reason,
@@ -17,7 +17,8 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -107,6 +108,11 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
json!(crate::ai_serving::transport::GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME),
);
}
if !resolved.compatibility_edits.is_empty() {
if let Ok(value) = serde_json::to_value(&resolved.compatibility_edits) {
extra_fields.insert("request_body_compatibility_edits".to_string(), value);
}
}
let provider_api_format = resolved.provider_api_format.clone();
let effective_headers = input.effective_headers(&parts.headers);
let report_context = append_local_failover_policy_to_value(
@@ -175,8 +181,10 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
provider_request_headers,
provider_request_body,
transport_profile: _,
compatibility_edits: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -203,6 +211,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -19,7 +19,9 @@ use crate::ai_serving::transport::{
build_gemini_cli_v1internal_request, build_grok_browser_headers, build_grok_upstream_url,
build_same_format_provider_headers, resolve_local_gemini_cli_request_auth,
GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestEnvelopeSupport,
GrokHeaderInput, SameFormatProviderHeadersInput, GEMINI_CLI_USER_AGENT, GROK_CHAT_PATH,
GrokHeaderInput, SameFormatProviderCompatibilityEdit,
SameFormatProviderCompatibilityEditAction, SameFormatProviderHeadersInput,
GEMINI_CLI_USER_AGENT, GROK_CHAT_PATH,
};
use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot};
use crate::{AppState, GatewayError};
@@ -107,6 +109,7 @@ pub(crate) struct LocalSameFormatProviderCandidatePayloadParts {
pub(super) provider_request_headers: BTreeMap<String, String>,
pub(super) provider_request_body: Value,
pub(super) transport_profile: Option<ResolvedTransportProfile>,
pub(super) compatibility_edits: Vec<SameFormatProviderCompatibilityEdit>,
pub(super) request_redacted: bool,
}
@@ -153,8 +156,8 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
let body_json = redaction.body_json.as_ref();
let mut transport = Arc::clone(&prepared.transport);
let Some(mut base_provider_request_body) =
super::super::request::build_same_format_provider_request_body(
let Some(base_provider_request) =
super::super::request::build_same_format_provider_request_body_with_compatibility_report(
body_json,
prepared.provider_api_format.as_str(),
&prepared.mapped_model,
@@ -190,6 +193,8 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
.await;
return Ok(None);
};
let mut base_provider_request_body = base_provider_request.body;
let mut compatibility_edits = base_provider_request.compatibility_edits;
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
@@ -198,10 +203,18 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
)
.await
{
let before_mapping = base_provider_request_body.clone();
crate::ai_serving::apply_model_directive_mapping_patch(
&mut base_provider_request_body,
&mapping,
);
if before_mapping != base_provider_request_body {
compatibility_edits.push(SameFormatProviderCompatibilityEdit {
field: "model_directive_mapping".to_string(),
action: SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
detail: "applied configured model directive mapping patch".to_string(),
});
}
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
// re-enforce stream-field policy afterward.
// Kiro behavior classification already hard-requires upstream streaming,
@@ -452,6 +465,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
provider_request_headers,
provider_request_body,
transport_profile,
compatibility_edits,
request_redacted: redaction.redacted,
}))
}
@@ -2,4 +2,5 @@ mod body;
mod url;
pub(super) use self::body::build_same_format_provider_request_body;
pub(super) use self::body::build_same_format_provider_request_body_with_compatibility_report;
pub(super) use self::url::build_same_format_upstream_url;
@@ -3,7 +3,9 @@ use serde_json::Value;
use super::super::LocalSameFormatProviderSpec;
use crate::ai_serving::transport::{
build_same_format_provider_request_body as build_same_format_provider_request_body_impl,
build_same_format_provider_request_body_with_compatibility_report as build_same_format_provider_request_body_with_compatibility_report_impl,
SameFormatProviderFamily, SameFormatProviderRequestBodyInput,
SameFormatProviderRequestBodyOutput,
};
pub(crate) fn build_same_format_provider_request_body(
@@ -36,6 +38,38 @@ pub(crate) fn build_same_format_provider_request_body(
})
}
pub(crate) fn build_same_format_provider_request_body_with_compatibility_report(
body_json: &Value,
provider_api_format: &str,
mapped_model: &str,
spec: LocalSameFormatProviderSpec,
body_rules: Option<&Value>,
request_headers: Option<&http::HeaderMap>,
upstream_is_stream: bool,
force_body_stream_field: bool,
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
is_claude_code: bool,
enable_model_directives: bool,
) -> Option<SameFormatProviderRequestBodyOutput> {
build_same_format_provider_request_body_with_compatibility_report_impl(
SameFormatProviderRequestBodyInput {
body_json,
mapped_model,
client_api_format: spec.api_format,
provider_api_format,
source_model: body_json.get("model").and_then(Value::as_str),
family: same_format_provider_family(spec.family),
body_rules,
request_headers,
upstream_is_stream,
force_body_stream_field,
kiro_auth_config: kiro_auth.map(|auth| &auth.auth_config),
is_claude_code,
enable_model_directives,
},
)
}
fn same_format_provider_family(
family: super::super::LocalSameFormatProviderFamily,
) -> SameFormatProviderFamily {
@@ -29,7 +29,6 @@ impl<'a> ProviderRequestRedaction<'a> {
#[derive(Clone, Copy, Debug, Default)]
struct ChatPiiRedactionFeatureSettings {
enabled: Option<bool>,
inject_model_instruction: Option<bool>,
}
impl ChatPiiRedactionFeatureSettings {
@@ -44,21 +43,11 @@ impl ChatPiiRedactionFeatureSettings {
if let Some(enabled) = settings.get("enabled").and_then(Value::as_bool) {
self.enabled = Some(enabled);
}
if let Some(inject_model_instruction) = settings
.get("inject_model_instruction")
.and_then(Value::as_bool)
{
self.inject_model_instruction = Some(inject_model_instruction);
}
}
fn effective_enabled(self) -> bool {
self.enabled.unwrap_or(false)
}
fn effective_inject_model_instruction(self) -> bool {
self.inject_model_instruction.unwrap_or(true)
}
}
pub(crate) fn request_identity_response_encoding_when_redacted(
@@ -122,7 +111,7 @@ pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
&body_bytes,
format,
build_redaction_session_config(hmac_key, &runtime_config, now_unix_secs),
MaskChatRequestOptions::runtime(feature_settings.effective_inject_model_instruction()),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -190,3 +179,22 @@ fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayEr
},
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::ChatPiiRedactionFeatureSettings;
#[test]
fn chat_pii_redaction_feature_settings_only_control_enablement() {
let mut settings = ChatPiiRedactionFeatureSettings::default();
settings.merge_from_value(Some(&json!({
"chat_pii_redaction": {
"enabled": true
}
})));
assert!(settings.effective_enabled());
}
}
@@ -0,0 +1,326 @@
use aether_ai_serving::AiRequestGzipPolicy;
use serde_json::Value;
use crate::ai_serving::is_openai_responses_family_format;
use super::state::GatewayProviderTransportSnapshot;
const DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES: usize = 64 * 1024;
pub(crate) fn resolve_transport_request_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
transport_request_gzip_policy_from_config(transport.endpoint.config.as_ref())
.or_else(|| transport_request_gzip_policy_from_config(transport.provider.config.as_ref()))
.or_else(|| default_transport_request_gzip_policy(transport))
}
fn default_transport_request_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
if !transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex")
{
return None;
}
if !is_codex_request_gzip_endpoint_api_format(transport.endpoint.api_format.as_str()) {
return None;
}
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
}
fn is_codex_request_gzip_endpoint_api_format(api_format: &str) -> bool {
is_openai_responses_family_format(api_format)
|| api_format.trim().eq_ignore_ascii_case("openai:image")
}
fn transport_request_gzip_policy_from_config(
config: Option<&Value>,
) -> Option<AiRequestGzipPolicy> {
let object = config?.as_object()?;
for key in ["request_gzip", "request_body_gzip"] {
if let Some(policy) = object
.get(key)
.and_then(transport_request_gzip_policy_from_value)
{
return Some(policy);
}
}
let enabled = first_config_bool(
object,
&["request_gzip_enabled", "request_body_gzip_enabled"],
);
let min_bytes = first_config_usize(
object,
&["request_gzip_min_bytes", "request_body_gzip_min_bytes"],
);
match (enabled, min_bytes) {
(Some(false), _) => Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
}),
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes,
}),
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(min_bytes),
}),
(None, None) => None,
}
}
fn transport_request_gzip_policy_from_value(value: &Value) -> Option<AiRequestGzipPolicy> {
if let Some(enabled) = value.as_bool() {
return Some(AiRequestGzipPolicy {
enabled: Some(enabled),
min_bytes: None,
});
}
let object = value.as_object()?;
let enabled = first_config_bool(object, &["enabled"]);
let min_bytes = first_config_usize(object, &["min_bytes"]);
match (enabled, min_bytes) {
(Some(false), _) => Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
}),
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes,
}),
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(min_bytes),
}),
(None, None) => None,
}
}
fn first_config_bool(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| object.get(*key).and_then(config_bool))
}
fn config_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| {
value.as_str().and_then(|text| {
let normalized = text.trim();
if normalized.eq_ignore_ascii_case("true") {
Some(true)
} else if normalized.eq_ignore_ascii_case("false") {
Some(false)
} else {
None
}
})
})
}
fn first_config_usize(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<usize> {
keys.iter()
.find_map(|key| object.get(*key).and_then(config_usize))
}
fn config_usize(value: &Value) -> Option<usize> {
value
.as_u64()
.and_then(|number| usize::try_from(number).ok())
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<usize>().ok())
})
}
#[cfg(test)]
mod tests {
use super::*;
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use serde_json::{json, Value};
fn sample_transport(
provider_type: &str,
endpoint_api_format: &str,
provider_config: Option<Value>,
endpoint_config: Option<Value>,
) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "Provider".to_string(),
provider_type: provider_type.to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: provider_config,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: endpoint_api_format.to_string(),
api_family: None,
endpoint_kind: None,
is_active: true,
base_url: "https://api.example.test".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: endpoint_config,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "api_key".to_string(),
is_active: true,
api_formats: None,
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
upstream_metadata: None,
decrypted_api_key: "secret".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn endpoint_request_gzip_policy_overrides_provider_policy() {
let transport = sample_transport(
"openai",
"openai:responses",
Some(json!({"request_gzip": false})),
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1024),
})
);
}
#[test]
fn endpoint_request_gzip_false_disables_provider_and_codex_defaults() {
let transport = sample_transport(
"codex",
"openai:responses",
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
Some(json!({"request_gzip": false})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
})
);
}
#[test]
fn request_gzip_policy_supports_top_level_aliases() {
let transport = sample_transport(
"openai",
"openai:responses",
None,
Some(json!({
"request_body_gzip_enabled": true,
"request_body_gzip_min_bytes": "4096"
})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(4096),
})
);
}
#[test]
fn request_gzip_policy_treats_min_bytes_only_as_enabled() {
let transport = sample_transport(
"openai",
"openai:responses",
None,
Some(json!({"request_gzip_min_bytes": 1})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1),
})
);
}
#[test]
fn codex_responses_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:responses", None, None);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
);
}
#[test]
fn codex_image_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:image", None, None);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
);
}
#[test]
fn non_codex_endpoint_does_not_get_default_request_gzip_policy() {
let transport = sample_transport("openai", "openai:responses", None, None);
assert_eq!(resolve_transport_request_gzip_policy(&transport), None);
}
}
@@ -7,7 +7,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -123,6 +124,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
upstream_url,
file_name: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -154,6 +156,8 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -5,7 +5,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -82,15 +83,6 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
"chatgpt_web_image".to_string(),
serde_json::Value::Bool(true),
);
extra_fields.insert(
"local_failover_policy".to_string(),
serde_json::json!({
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
"error_stop_patterns": [
{ "pattern": ".*" }
]
}),
);
}
let upstream_is_stream = resolved
.provider_request_body
@@ -143,6 +135,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
spec_metadata.api_format,
provider_api_format.as_str(),
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -169,6 +162,8 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
provider_request_body: Some(resolved.provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -5,7 +5,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -103,6 +104,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
provider_request_body,
upstream_url,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: false,
@@ -135,6 +137,8 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use super::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
};
use crate::ai_serving::planner::standard::build_local_openai_responses_request_body;
use http::{HeaderMap, HeaderValue};
use serde_json::json;
@@ -36,6 +37,40 @@ fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
assert!(body.get("reasoning").is_none());
}
#[test]
fn local_openai_responses_codex_body_wraps_string_input_for_backend() {
let body = json!({
"model": "gpt-5",
"input": "hello"
});
let provider_request_body = build_local_openai_responses_request_body(
&body,
"gpt-5-upstream",
false,
false,
"codex",
"openai:responses",
None,
Some("key-123"),
&HeaderMap::new(),
false,
)
.expect("codex local openai responses body should build");
assert_eq!(
provider_request_body["input"],
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "hello"
}]
}])
);
}
#[test]
fn strips_store_for_compact_even_when_body_rules_handle_it() {
let body_rules = json!([
@@ -15,7 +15,8 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -175,6 +176,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
transport_profile: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -201,6 +203,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -23,6 +23,7 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_headers, apply_deepseek_tool_call_thinking_compat,
is_deepseek_provider, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -599,10 +600,14 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"standard_family_request_conversion",
),
)
.await;
@@ -62,7 +62,8 @@ pub(crate) use crate::ai_serving::{
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub(crate) use aether_ai_serving::{
request_body_build_failure_extra_data, same_format_provider_request_body_failure_extra_data,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
same_format_provider_request_body_failure_extra_data,
};
pub(crate) fn build_standard_upstream_url(
@@ -6,7 +6,8 @@ use crate::ai_serving::planner::report_context::{
insert_provider_stream_event_api_format, LocalExecutionReportContextParts,
};
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -104,15 +105,6 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
.eq_ignore_ascii_case("chatgpt_web")
{
extra_fields.insert("chatgpt_web_image".to_string(), serde_json::json!(true));
extra_fields.insert(
"local_failover_policy".to_string(),
serde_json::json!({
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
"error_stop_patterns": [
{ "pattern": ".*" }
]
}),
);
}
let super::request::LocalOpenAiChatCandidatePayloadParts {
client_api_format,
@@ -192,6 +184,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
),
&transport,
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream,
@@ -218,6 +211,8 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -25,6 +25,7 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
use crate::ai_serving::transport::kiro::{
@@ -601,10 +602,14 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
"openai:chat",
provider_api_format.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_chat_request_conversion",
),
)
.await;
@@ -326,6 +326,8 @@ mod tests {
})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -422,6 +424,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":true})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -488,6 +492,8 @@ mod tests {
provider_request_body,
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -595,6 +601,8 @@ mod tests {
),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -292,6 +292,8 @@ mod tests {
})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -387,6 +389,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":false})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -458,6 +462,8 @@ mod tests {
),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -9,7 +9,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -101,15 +102,6 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
.eq_ignore_ascii_case("chatgpt_web")
{
extra_fields.insert("chatgpt_web_image".to_string(), json!(true));
extra_fields.insert(
"local_failover_policy".to_string(),
json!({
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
"error_stop_patterns": [
{ "pattern": ".*" }
]
}),
);
}
insert_provider_stream_event_api_format(
&mut extra_fields,
@@ -212,6 +204,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
image_request_summary: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -238,6 +231,8 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -27,6 +27,7 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::{
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
@@ -371,10 +372,14 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_responses_request_conversion",
),
)
.await;
@@ -72,8 +72,10 @@ pub(crate) use aether_provider_transport::{
build_local_openai_chat_upstream_url, build_local_openai_responses_upstream_url,
build_openai_image_headers, build_openai_image_upstream_url, build_passthrough_headers,
build_request_trace_proxy_value, build_same_format_provider_headers,
build_same_format_provider_request_body, build_same_format_provider_upstream_url,
build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url,
build_same_format_provider_request_body,
build_same_format_provider_request_body_with_compatibility_report,
build_same_format_provider_upstream_url, build_standard_plan_fallback_headers,
build_standard_plan_fallback_openai_chat_url,
build_standard_plan_fallback_openai_responses_url, build_standard_provider_request_headers,
build_transport_request_url, build_transport_request_url_for_request_body,
build_video_create_headers, build_video_create_request_body, build_video_create_upstream_url,
@@ -106,12 +108,14 @@ pub(crate) use aether_provider_transport::{
GeminiCliRequestAuthUnsupportedReason, GeminiCliRequestEnvelopeSupport,
GeminiFilesHeadersInput, GeminiFilesRequestBodyError, GeminiFilesRequestBodyParts,
GrokHeaderInput, LocalResolvedOAuthRequestAuth, ProviderOpenAiImageHeadersInput,
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput, SameFormatProviderFamily,
SameFormatProviderHeadersInput, SameFormatProviderRequestBehavior,
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput,
SameFormatProviderCompatibilityEdit, SameFormatProviderCompatibilityEditAction,
SameFormatProviderFamily, SameFormatProviderHeadersInput, SameFormatProviderRequestBehavior,
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
SameFormatProviderUpstreamUrlParams, StandardPlanFallbackAcceptPolicy,
StandardPlanFallbackHeadersInput, StandardProviderRequestHeaders,
StandardProviderRequestHeadersInput, TransportRequestBodySemanticsError,
TransportRequestUrlParams, GEMINI_CLI_USER_AGENT, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
GROK_CHAT_PATH, GROK_INTERNAL_HEADER, GROK_RATE_LIMITS_PATH, WINDSURF_ENVELOPE_NAME,
SameFormatProviderRequestBodyOutput, SameFormatProviderUpstreamUrlParams,
StandardPlanFallbackAcceptPolicy, StandardPlanFallbackHeadersInput,
StandardProviderRequestHeaders, StandardProviderRequestHeadersInput,
TransportRequestBodySemanticsError, TransportRequestUrlParams, GEMINI_CLI_USER_AGENT,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, GROK_CHAT_PATH, GROK_INTERNAL_HEADER,
GROK_RATE_LIMITS_PATH, WINDSURF_ENVELOPE_NAME,
};
+9 -3
View File
@@ -11,9 +11,15 @@ pub(crate) async fn read_request_candidate_trace(
request_id: &str,
attempted_only: bool,
) -> Result<Option<RequestCandidateTrace>, DataLayerError> {
let all_candidates = state
.list_request_candidates_by_request_id(request_id)
.await?;
let all_candidates = if attempted_only {
state
.list_attempted_request_candidates_by_request_id(request_id)
.await?
} else {
state
.list_request_candidates_by_request_id(request_id)
.await?
};
Ok(RequestCandidateTrace::from_candidates(
request_id,
all_candidates,
+18 -2
View File
@@ -19,6 +19,16 @@ impl GatewayDataState {
}
}
pub(crate) async fn list_attempted_request_candidates_by_request_id(
&self,
request_id: &str,
) -> Result<Vec<StoredRequestCandidate>, DataLayerError> {
match &self.request_candidate_reader {
Some(repository) => repository.list_attempted_by_request_id(request_id).await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn list_request_candidates_by_provider_id(
&self,
provider_id: &str,
@@ -416,18 +426,24 @@ impl GatewayDataState {
pub(crate) async fn cleanup_deleted_provider_catalog_refs(
&self,
provider_id: &str,
provider_deleted: bool,
endpoint_ids: &[String],
key_ids: &[String],
) -> Result<(), DataLayerError> {
let cleaned = match &self.provider_catalog_writer {
Some(repository) => {
repository
.cleanup_deleted_provider_refs(provider_id, endpoint_ids, key_ids)
.cleanup_deleted_provider_refs(
provider_id,
provider_deleted,
endpoint_ids,
key_ids,
)
.await
}
None => Ok(()),
};
if !endpoint_ids.is_empty() || !key_ids.is_empty() {
if provider_deleted || !endpoint_ids.is_empty() || !key_ids.is_empty() {
self.clear_provider_catalog_cache();
}
cleaned
@@ -1124,6 +1124,16 @@ impl GatewayDataState {
}
}
pub(crate) async fn find_request_usage_by_request_id_shallow(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
match &self.usage_reader {
Some(repository) => repository.find_by_request_id_shallow(request_id).await,
None => Ok(None),
}
}
pub(crate) async fn find_request_usage_by_id(
&self,
usage_id: &str,
@@ -1958,6 +1968,14 @@ impl GatewayDataState {
self.find_request_usage_by_request_id(request_id).await
}
pub(crate) async fn read_request_usage_audit_shallow(
&self,
request_id: &str,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
self.find_request_usage_by_request_id_shallow(request_id)
.await
}
pub(crate) async fn read_request_audit_bundle(
&self,
request_id: &str,
@@ -4,7 +4,9 @@ use std::sync::{
Arc, LazyLock,
};
use aether_admin::provider::pool as admin_provider_pool_pure;
use aether_admin::provider::{
pool as admin_provider_pool_pure, status as admin_provider_status_pure,
};
use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder,
StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery,
@@ -1217,9 +1219,15 @@ fn pool_key_requires_reauth_for_scheduling(
.map(str::trim)
.unwrap_or_default();
if !invalid_reason.is_empty() {
if pool_oauth_reason_has_tag(invalid_reason, "[OAUTH_EXPIRED]")
|| pool_oauth_reason_has_tag(invalid_reason, "[ACCOUNT_BLOCK]")
{
let account_state = admin_provider_status_pure::resolve_pool_account_state(
None,
key.upstream_metadata.as_ref(),
Some(invalid_reason),
);
if account_state.blocked && !account_state.recoverable {
return true;
}
if pool_oauth_reason_has_tag(invalid_reason, "[ACCOUNT_BLOCK]") {
return true;
}
if pool_oauth_reason_has_tag(invalid_reason, "[REQUEST_FAILED]") {
@@ -1230,6 +1238,9 @@ fn pool_key_requires_reauth_for_scheduling(
.expires_at_unix_secs
.is_none_or(|expires_at| expires_at == 0 || expires_at <= now_unix_secs);
}
if pool_oauth_reason_has_tag(invalid_reason, "[OAUTH_EXPIRED]") {
return false;
}
return true;
}
@@ -3265,8 +3276,7 @@ mod tests {
let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid");
key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key_a_invalid.oauth_invalid_reason =
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)".to_string());
key_a_invalid.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
let exhausted_status_snapshot = json!({
"quota": {
"provider_type": "codex",
@@ -3370,8 +3380,7 @@ mod tests {
let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid");
key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key_a_invalid.oauth_invalid_reason =
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)".to_string());
key_a_invalid.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
key_a_invalid.status_snapshot = Some(json!({
"quota": {
"provider_type": "codex",
@@ -3463,6 +3472,9 @@ mod tests {
key.oauth_invalid_reason = Some("[REQUEST_FAILED] 账号状态检查失败".to_string());
key.oauth_invalid_at_unix_secs = Some(100);
assert!(!pool_key_requires_reauth_for_scheduling(&key, 300));
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
assert!(!pool_key_requires_reauth_for_scheduling(&key, 300));
}
#[test]
@@ -3471,6 +3483,10 @@ mod tests {
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
key.oauth_invalid_at_unix_secs = None;
assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
key.oauth_invalid_reason = Some("Kiro Token 无效或已过期".to_string());
key.oauth_invalid_at_unix_secs = None;
assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
@@ -1007,6 +1007,100 @@ mod tests {
);
}
#[tokio::test]
async fn provider_failover_rules_can_stop_rate_limit_status() {
let result = ExecutionResult {
request_id: "req-1".to_string(),
candidate_id: None,
status_code: 429,
headers: Default::default(),
body: None,
telemetry: None,
error: None,
};
let local_report_context = serde_json::json!({
"candidate_index": 0,
"retry_index": 0,
});
let state = build_state_with_provider_config(Some(serde_json::json!({
"failover_rules": {
"stop_on_status_codes": [429]
}
})));
let plan = sample_plan();
assert!(
should_stop_local_candidate_failover_sync(
&state,
&plan,
"openai_chat_sync",
Some(&local_report_context),
&result,
Some("{\"error\":{\"message\":\"rate limited\"}}"),
)
.await
);
assert!(
!should_retry_next_local_candidate_sync(
&state,
&plan,
"openai_chat_sync",
Some(&local_report_context),
&result,
Some("{\"error\":{\"message\":\"rate limited\"}}"),
)
.await
);
}
#[tokio::test]
async fn status_only_error_stop_rule_can_stop_rate_limit_status() {
let result = ExecutionResult {
request_id: "req-1".to_string(),
candidate_id: None,
status_code: 429,
headers: Default::default(),
body: None,
telemetry: None,
error: None,
};
let local_report_context = serde_json::json!({
"candidate_index": 0,
"retry_index": 0,
});
let state = build_state_with_provider_config(Some(serde_json::json!({
"failover_rules": {
"error_stop_patterns": [
{"status_codes": [429]}
]
}
})));
let plan = sample_plan();
assert!(
should_stop_local_candidate_failover_sync(
&state,
&plan,
"openai_chat_sync",
Some(&local_report_context),
&result,
None,
)
.await
);
assert!(
!should_retry_next_local_candidate_sync(
&state,
&plan,
"openai_chat_sync",
Some(&local_report_context),
&result,
None,
)
.await
);
}
#[test]
fn resolve_local_failover_policy_reads_regex_rules() {
let state = build_state_with_provider_config(Some(serde_json::json!({
@@ -1039,6 +1133,32 @@ mod tests {
);
}
#[test]
fn resolve_local_failover_policy_reads_status_only_error_stop_rules() {
let state = build_state_with_provider_config(Some(serde_json::json!({
"failover_rules": {
"success_failover_patterns": [
{"status_codes": [200]}
],
"error_stop_patterns": [
{"status_codes": [429]}
]
}
})));
let plan = sample_plan();
let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
let policy = runtime.block_on(resolve_local_failover_policy(&state, &plan, None));
assert!(policy.success_failover_patterns.is_empty());
assert_eq!(
policy.error_stop_patterns,
vec![LocalFailoverRegexRule {
pattern: String::new(),
status_codes: [429].into_iter().collect(),
}]
);
}
#[tokio::test]
async fn success_failover_pattern_can_retry_sync_candidate() {
let result = ExecutionResult {
@@ -1125,11 +1245,11 @@ mod tests {
}
#[tokio::test]
async fn chatgpt_web_report_context_stops_local_sync_failover_on_transport_errors() {
async fn report_context_failover_policy_does_not_override_provider_config() {
let result = ExecutionResult {
request_id: "req-1".to_string(),
candidate_id: None,
status_code: 503,
status_code: 429,
headers: Default::default(),
body: None,
telemetry: None,
@@ -1150,7 +1270,7 @@ mod tests {
let plan = sample_plan();
assert!(
should_stop_local_candidate_failover_sync(
should_retry_next_local_candidate_sync(
&state,
&plan,
"openai_image_sync",
@@ -1161,7 +1281,7 @@ mod tests {
.await
);
assert!(
!should_retry_next_local_candidate_sync(
!should_stop_local_candidate_failover_sync(
&state,
&plan,
"openai_image_sync",
@@ -3921,10 +3921,14 @@ mod tests {
StreamFrame, StreamFramePayload, StreamFrameType,
};
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::usage::InMemoryUsageReadRepository;
use aether_data_contracts::repository::candidates::{
RequestCandidateReadRepository, RequestCandidateStatus,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data_contracts::repository::usage::UsageReadRepository;
use aether_usage_runtime::UsageRuntimeConfig;
use async_stream::stream;
@@ -3954,6 +3958,77 @@ mod tests {
use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
use crate::AppState;
fn provider_catalog_stop_429_for_plan(
plan: &ExecutionPlan,
) -> InMemoryProviderCatalogReadRepository {
let provider_type = plan.provider_name.as_deref().unwrap_or("custom");
let provider = StoredProviderCatalogProvider::new(
plan.provider_id.clone(),
plan.provider_id.clone(),
Some("https://provider.example".to_string()),
provider_type.to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
false,
false,
None,
Some(3),
None,
None,
None,
Some(json!({
"failover_rules": {
"stop_status_codes": [429]
}
})),
);
let endpoint = StoredProviderCatalogEndpoint::new(
plan.endpoint_id.clone(),
plan.provider_id.clone(),
plan.provider_api_format.clone(),
None,
None,
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://provider.example".to_string(),
None,
None,
Some(2),
None,
None,
None,
None,
)
.expect("endpoint transport should build");
let key = StoredProviderCatalogKey::new(
plan.key_id.clone(),
plan.provider_id.clone(),
plan.key_id.clone(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!([plan.provider_api_format.clone()])),
"plain-upstream-key".to_string(),
None,
None,
Some(json!({ "openai:chat": 1 })),
None,
None,
None,
None,
)
.expect("key transport should build");
InMemoryProviderCatalogReadRepository::seed(vec![provider], vec![endpoint], vec![key])
}
fn test_decision() -> GatewayControlDecision {
GatewayControlDecision::synthetic(
"/v1/chat/completions",
@@ -5458,6 +5533,14 @@ mod tests {
transport_profile: None,
timeouts: None,
};
let state = state.with_data_state_for_tests(
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
Arc::clone(&request_candidate_repository),
Arc::clone(&usage_repository),
)
.with_provider_catalog_reader(Arc::new(provider_catalog_stop_429_for_plan(&plan)))
.with_encryption_key_for_tests("development-key"),
);
let trailer_error = connect_json_frame(
2,
br#"{"error":{"code":"resource_exhausted","message":"an internal error occurred"}}"#,
@@ -5493,10 +5576,7 @@ mod tests {
"client_api_format": "claude:messages",
"needs_conversion": true,
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"local_failover_policy": {
"stop_status_codes": [429]
}
"envelope_name": "windsurf:GetChatMessage"
})),
crate::clock::current_unix_ms(),
Instant::now(),
@@ -5590,6 +5670,14 @@ mod tests {
transport_profile: None,
timeouts: None,
};
let state = state.with_data_state_for_tests(
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
Arc::clone(&request_candidate_repository),
Arc::clone(&usage_repository),
)
.with_provider_catalog_reader(Arc::new(provider_catalog_stop_429_for_plan(&plan)))
.with_encryption_key_for_tests("development-key"),
);
let connect_error = connect_json_frame(
2,
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
@@ -5624,10 +5712,7 @@ mod tests {
"client_api_format": "claude:messages",
"needs_conversion": true,
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"local_failover_policy": {
"stop_status_codes": [429]
}
"envelope_name": "windsurf:GetChatMessage"
})),
crate::clock::current_unix_ms(),
Instant::now(),
@@ -16,6 +16,8 @@ pub(super) fn decode_execution_result_body(
};
if let Some(json_body) = body.json_body {
remove_header_case_insensitive(headers, "content-encoding");
remove_header_case_insensitive(headers, "content-length");
headers
.entry("content-type".to_string())
.or_insert_with(|| "application/json".to_string());
@@ -34,3 +36,49 @@ pub(super) fn decode_execution_result_body(
Ok((Vec::new(), None, None))
}
fn remove_header_case_insensitive(headers: &mut BTreeMap<String, String>, name: &str) {
if let Some(existing_key) = headers
.keys()
.find(|key| key.eq_ignore_ascii_case(name))
.cloned()
{
headers.remove(&existing_key);
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use aether_contracts::ResponseBody;
use serde_json::json;
use super::decode_execution_result_body;
#[test]
fn decoded_json_body_drops_stale_content_encoding_headers() {
let mut headers = BTreeMap::from([
("content-encoding".to_string(), "gzip".to_string()),
("content-length".to_string(), "999".to_string()),
]);
let (body_bytes, body_json, body_base64) = decode_execution_result_body(
Some(ResponseBody {
json_body: Some(json!({"ok": true})),
body_bytes_b64: None,
}),
&mut headers,
)
.expect("body should decode");
assert_eq!(body_json, Some(json!({"ok": true})));
assert_eq!(body_base64, None);
assert_eq!(body_bytes, br#"{"ok":true}"#);
assert_eq!(headers.get("content-encoding"), None);
assert_eq!(
headers.get("content-length").cloned(),
Some(body_bytes.len().to_string())
);
}
}
@@ -63,6 +63,8 @@ fn missing_exact_provider_request_payload(decision_kind: &str) -> AiExecutionDec
provider_request_body: None,
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
+133 -1
View File
@@ -111,6 +111,38 @@ impl LocalExecutionRuntimeMissContext {
}
Some(summaries.join(" | "))
}
pub(crate) fn all_provider_request_body_build_failures_detail(&self) -> Option<String> {
if self.candidate_contexts.is_empty()
|| !self.candidate_contexts.iter().all(|candidate| {
candidate.candidate.status == RequestCandidateStatus::Skipped
&& candidate
.candidate
.skip_reason
.as_deref()
.map(str::trim)
.is_some_and(|value| value == "provider_request_body_build_failed")
})
{
return None;
}
let diagnostic = self
.candidate_contexts
.iter()
.find_map(runtime_miss_candidate_failure_diagnostic)?;
let mut detail = format!("上游请求体转换失败:{}", diagnostic.message);
if diagnostic.path != "$" {
detail.push_str(&format!(";字段路径:{}", diagnostic.path));
}
detail.push_str("(原因代码: provider_request_body_build_failed");
Some(detail)
}
}
struct RuntimeMissFailureDiagnostic {
path: String,
message: String,
}
pub(crate) async fn build_local_execution_exhaustion(
@@ -835,6 +867,41 @@ fn candidate_extra_data_string(candidate: &StoredRequestCandidate, key: &str) ->
.map(ToOwned::to_owned)
}
fn runtime_miss_candidate_failure_diagnostic(
candidate: &RuntimeMissCandidateContext,
) -> Option<RuntimeMissFailureDiagnostic> {
let extra_data = candidate.candidate.extra_data.as_ref()?.as_object()?;
let diagnostic = extra_data
.get("failure_diagnostic")
.and_then(Value::as_object)
.filter(|diagnostic| diagnostic.get("safe_to_show") != Some(&Value::Bool(false)))
.or_else(|| {
extra_data
.get("request_conversion_error")
.and_then(Value::as_object)
})
.or_else(|| {
extra_data
.get("request_body_build_error")
.and_then(Value::as_object)
})?;
let message = diagnostic
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let path = diagnostic
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("$");
Some(RuntimeMissFailureDiagnostic {
path: path.to_string(),
message: message.to_string(),
})
}
fn build_runtime_miss_candidate_endpoint_url(
candidate: &StoredRequestCandidate,
endpoint: &StoredProviderCatalogEndpoint,
@@ -1036,7 +1103,8 @@ mod tests {
use super::{
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
request_candidate_represents_provider_execution,
select_last_runtime_miss_executed_candidate, RuntimeMissCandidateContext,
select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext,
RuntimeMissCandidateContext,
};
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
use crate::state::LocalExecutionRuntimeMissDiagnostic;
@@ -1161,4 +1229,68 @@ mod tests {
assert!(select_last_runtime_miss_executed_candidate(&contexts).is_none());
}
#[test]
fn runtime_miss_context_surfaces_request_conversion_field_diagnostic() {
let skipped_candidate = StoredRequestCandidate::new(
"cand-skipped".to_string(),
"req-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
0,
0,
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("provider-key-1".to_string()),
RequestCandidateStatus::Skipped,
Some("provider_request_body_build_failed".to_string()),
false,
None,
None,
None,
None,
None,
Some(json!({
"failure_diagnostic": {
"kind": "request_conversion",
"path": "$.n",
"message": "openai:chat 字段 n 不能无损转换到 openai:responsesOpenAI Responses request has no canonical equivalent for this Chat field",
"safe_to_show": true
},
"request_conversion_error": {
"path": "$.n",
"message": "compat"
}
})),
None,
100,
None,
None,
)
.expect("candidate should build");
let context = LocalExecutionRuntimeMissContext {
candidate_contexts: vec![RuntimeMissCandidateContext {
candidate: skipped_candidate,
provider_name: Some("openai".to_string()),
key_name: Some("prod".to_string()),
client_api_format: Some("openai:chat".to_string()),
provider_api_format: Some("openai:responses".to_string()),
global_model_name: Some("gpt-5".to_string()),
selected_provider_model_name: Some("gpt-5-upstream".to_string()),
endpoint_url: Some("https://api.openai.example/v1/responses".to_string()),
}],
..LocalExecutionRuntimeMissContext::default()
};
let detail = context
.all_provider_request_body_build_failures_detail()
.expect("detail should include conversion diagnostic");
assert!(detail.contains("字段 n"));
assert!(detail.contains("字段路径:$.n"));
assert!(detail.contains("provider_request_body_build_failed"));
}
}
@@ -89,7 +89,7 @@ async fn resolve_admin_monitoring_trace(
{
let usage = app
.data
.read_request_usage_audit(request_id)
.read_request_usage_audit_shallow(request_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(ResolvedAdminMonitoringTrace { trace, usage }));
@@ -98,7 +98,7 @@ async fn resolve_admin_monitoring_trace(
let mut usage_candidates = Vec::new();
if let Some(usage) = app
.data
.read_request_usage_audit(request_id)
.read_request_usage_audit_shallow(request_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
@@ -14,17 +14,76 @@ use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data_contracts::repository::usage::UsageBodyField;
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use tokio::try_join;
struct AdminUsageDetailBodyValue {
value: Option<Value>,
load_failed: bool,
}
async fn resolve_admin_usage_detail_request_body(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
) -> AdminUsageDetailBodyValue {
match admin_usage_resolve_request_capture_body_for_item(state, item, None).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Err(err) => {
tracing::warn!(
error = ?err,
usage_id = %item.id,
request_id = %item.request_id,
field = UsageBodyField::RequestBody.as_storage_field(),
"failed to resolve admin usage detail body"
);
let value = admin_usage_resolve_request_capture_body(item, None);
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
value,
}
}
}
}
async fn resolve_admin_usage_detail_body_value(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
) -> AdminUsageDetailBodyValue {
let inline_body = item.body_value(field);
match admin_usage_resolve_body_value(state, item, inline_body, field).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Err(err) => {
tracing::warn!(
error = ?err,
usage_id = %item.id,
request_id = %item.request_id,
field = field.as_storage_field(),
"failed to resolve admin usage detail body"
);
let value = inline_body.cloned();
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
value,
}
}
}
}
pub(super) async fn maybe_build_local_admin_usage_detail_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -174,32 +233,40 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
let mut detail_item = item.clone();
let mut body_load_errors = serde_json::Map::new();
let request_body = if include_bodies {
let (request_body, provider_request_body, response_body, client_response_body) = try_join!(
admin_usage_resolve_request_capture_body_for_item(state, &item, None),
admin_usage_resolve_body_value(
let (request_body, provider_request_body, response_body, client_response_body) = tokio::join!(
resolve_admin_usage_detail_request_body(state, &item),
resolve_admin_usage_detail_body_value(
state,
&item,
item.provider_request_body.as_ref(),
UsageBodyField::ProviderRequestBody,
),
admin_usage_resolve_body_value(
resolve_admin_usage_detail_body_value(
state,
&item,
item.response_body.as_ref(),
UsageBodyField::ResponseBody,
),
admin_usage_resolve_body_value(
resolve_admin_usage_detail_body_value(
state,
&item,
item.client_response_body.as_ref(),
UsageBodyField::ClientResponseBody,
),
)?;
detail_item.provider_request_body = provider_request_body;
detail_item.response_body = response_body;
detail_item.client_response_body = client_response_body;
request_body
);
for (field, resolved) in [
(UsageBodyField::RequestBody, &request_body),
(UsageBodyField::ProviderRequestBody, &provider_request_body),
(UsageBodyField::ResponseBody, &response_body),
(UsageBodyField::ClientResponseBody, &client_response_body),
] {
if resolved.load_failed {
body_load_errors.insert(field.as_storage_field().to_string(), json!(true));
}
}
detail_item.provider_request_body = provider_request_body.value;
detail_item.response_body = response_body.value;
detail_item.client_response_body = client_response_body.value;
request_body.value
} else {
None
};
@@ -207,7 +274,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
// request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。
}
let default_headers = admin_usage_curl_headers();
let payload = build_admin_usage_detail_payload(
let mut payload = build_admin_usage_detail_payload(
&detail_item,
&users_by_id,
&api_key_names,
@@ -218,6 +285,11 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
request_body,
&default_headers,
);
payload["body_load_errors"] = if include_bodies && !body_load_errors.is_empty() {
Value::Object(body_load_errors)
} else {
Value::Null
};
return Ok(Some(attach_admin_audit_response(
Json(payload).into_response(),
@@ -5,13 +5,12 @@ use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value;
use crate::GatewayError;
use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_client_family,
admin_usage_data_unavailable_response, admin_usage_has_fallback, admin_usage_is_failed,
admin_usage_matches_search, admin_usage_matches_username, admin_usage_parse_ids,
admin_usage_parse_limit, admin_usage_parse_offset, admin_usage_provider_key_name,
admin_usage_record_json, build_admin_usage_active_requests_response,
build_admin_usage_records_response, build_admin_usage_summary_stats_response_from_summary,
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
admin_usage_has_fallback, admin_usage_is_failed, admin_usage_matches_search,
admin_usage_matches_username, admin_usage_parse_ids, admin_usage_parse_limit,
admin_usage_parse_offset, admin_usage_provider_key_name, admin_usage_record_json,
build_admin_usage_active_requests_response, build_admin_usage_records_response,
build_admin_usage_summary_stats_response_from_summary, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data::repository::users::StoredUserSummary;
use aether_data_contracts::repository::{
@@ -195,29 +194,51 @@ async fn resolve_admin_usage_attempt_flags_by_usage_id(
.collect())
}
async fn resolve_admin_usage_image_progress_by_request_id(
#[derive(Default)]
struct AdminUsageActiveCandidateState {
image_progress_by_request_id: BTreeMap<String, serde_json::Value>,
state_overrides_by_request_id: BTreeMap<String, serde_json::Value>,
}
async fn resolve_admin_usage_active_candidate_state(
state: &AdminAppState<'_>,
items: &[StoredRequestUsageAudit],
) -> Result<BTreeMap<String, serde_json::Value>, GatewayError> {
) -> Result<AdminUsageActiveCandidateState, GatewayError> {
if !state.has_request_candidate_data_reader() || items.is_empty() {
return Ok(BTreeMap::new());
return Ok(AdminUsageActiveCandidateState::default());
}
let request_ids = items
.iter()
.map(|item| item.request_id.clone())
.collect::<BTreeSet<_>>();
let mut progress_by_request_id = BTreeMap::new();
let active_usage_by_request_id = items
.iter()
.filter(|item| matches!(item.status.as_str(), "pending" | "streaming"))
.map(|item| (item.request_id.clone(), item))
.collect::<BTreeMap<_, _>>();
let mut candidate_state = AdminUsageActiveCandidateState::default();
for request_id in request_ids {
let candidates = state
.app()
.read_request_candidates_by_request_id(&request_id)
.await?;
if let Some(progress) = latest_admin_usage_image_progress(&candidates) {
progress_by_request_id.insert(request_id, progress);
candidate_state
.image_progress_by_request_id
.insert(request_id.clone(), progress);
}
if active_usage_by_request_id.contains_key(&request_id) {
if let Some(override_payload) =
admin_usage_terminal_candidate_state_override(&candidates)
{
candidate_state
.state_overrides_by_request_id
.insert(request_id, override_payload);
}
}
}
Ok(progress_by_request_id)
Ok(candidate_state)
}
fn latest_admin_usage_image_progress(
@@ -246,6 +267,82 @@ fn latest_admin_usage_image_progress(
.map(|(_, _, _, progress)| progress)
}
fn admin_usage_current_candidate(
candidates: &[StoredRequestCandidate],
) -> Option<&StoredRequestCandidate> {
candidates
.iter()
.filter(|candidate| {
!matches!(
candidate.status,
RequestCandidateStatus::Available
| RequestCandidateStatus::Unused
| RequestCandidateStatus::Skipped
)
})
.max_by_key(|candidate| {
(
candidate.candidate_index,
candidate.retry_index,
candidate
.started_at_unix_ms
.or(candidate.finished_at_unix_ms)
.unwrap_or(candidate.created_at_unix_ms),
)
})
}
fn admin_usage_unix_millis_to_rfc3339(unix_ms: u64) -> Option<String> {
let secs = i64::try_from(unix_ms / 1_000).ok()?;
let nanos = u32::try_from(unix_ms % 1_000)
.ok()?
.saturating_mul(1_000_000);
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nanos)
.map(|timestamp| timestamp.to_rfc3339())
}
fn admin_usage_terminal_candidate_state_override(
candidates: &[StoredRequestCandidate],
) -> Option<serde_json::Value> {
let candidate = admin_usage_current_candidate(candidates)?;
let status = match candidate.status {
RequestCandidateStatus::Success => "completed",
RequestCandidateStatus::Failed => "failed",
RequestCandidateStatus::Cancelled => "cancelled",
_ => return None,
};
let latency_ms = candidate.latency_ms.or_else(|| {
Some(
candidate
.finished_at_unix_ms?
.saturating_sub(candidate.started_at_unix_ms?),
)
});
let mut payload = json!({ "status": status });
if let Some(latency_ms) = latency_ms {
payload["response_time_ms"] = json!(latency_ms);
if let Some(response_time_updated_at) = candidate
.finished_at_unix_ms
.or_else(|| {
candidate
.started_at_unix_ms
.map(|started_at| started_at.saturating_add(latency_ms))
})
.and_then(admin_usage_unix_millis_to_rfc3339)
{
payload["response_time_updated_at"] = json!(response_time_updated_at);
}
}
if let Some(status_code) = candidate.status_code {
payload["status_code"] = json!(status_code);
}
if let Some(error_message) = candidate.error_message.as_ref() {
payload["error_message"] = json!(error_message);
}
Some(payload)
}
fn admin_usage_matches_attempt_status(
item: &StoredRequestUsageAudit,
status: &str,
@@ -264,19 +361,6 @@ fn admin_usage_matches_attempt_status(
}
}
fn admin_usage_matches_client_family(
item: &StoredRequestUsageAudit,
client_family: Option<&str>,
) -> bool {
let Some(client_family) = client_family
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return true;
};
admin_usage_client_family(item).is_some_and(|value| value.eq_ignore_ascii_case(client_family))
}
fn admin_usage_bool_query_param(query: Option<&str>, name: &str) -> bool {
query_param_value(query, name)
.as_deref()
@@ -290,15 +374,24 @@ fn admin_usage_bool_query_param(query: Option<&str>, name: &str) -> bool {
.unwrap_or(false)
}
fn admin_usage_is_unknown_label(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"unknown" | "unknow"
)
fn admin_usage_include_total_query_param(query: Option<&str>) -> bool {
query_param_value(query, "include_total")
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
!(value == "0"
|| value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
|| value.eq_ignore_ascii_case("off"))
})
.unwrap_or(true)
}
fn admin_usage_has_unknown_model_or_provider(item: &StoredRequestUsageAudit) -> bool {
admin_usage_is_unknown_label(&item.model) || admin_usage_is_unknown_label(&item.provider_name)
fn admin_usage_fast_page_total(offset: usize, limit: usize, record_count: usize) -> usize {
offset
.saturating_add(record_count)
.saturating_add(usize::from(limit > 0 && record_count == limit))
}
#[allow(clippy::too_many_arguments)]
@@ -314,6 +407,7 @@ fn build_admin_usage_records_response_with_attempt_flags(
total: usize,
limit: usize,
offset: usize,
total_is_estimated: bool,
) -> Response<Body> {
let records: Vec<_> = items
.iter()
@@ -343,6 +437,7 @@ fn build_admin_usage_records_response_with_attempt_flags(
"total": total,
"limit": limit,
"offset": offset,
"total_is_estimated": total_is_estimated,
}))
.into_response()
}
@@ -485,6 +580,8 @@ fn build_admin_usage_keyword_search_query(
provider_name: base_query.provider_name.clone(),
model: base_query.model.clone(),
api_format: base_query.api_format.clone(),
client_family: base_query.client_family.clone(),
exclude_unknown_model_or_provider: base_query.exclude_unknown_model_or_provider,
statuses: base_query.statuses.clone(),
exclude_status_codes: base_query.exclude_status_codes.clone(),
is_stream: base_query.is_stream,
@@ -583,6 +680,7 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
state.has_auth_api_key_data_reader(),
&BTreeMap::new(),
&BTreeMap::new(),
&BTreeMap::new(),
)));
};
state
@@ -606,15 +704,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
};
let api_key_names = admin_usage_api_key_names(state, &items).await?;
let provider_key_names = admin_usage_provider_key_names(state, &items).await?;
let image_progress_by_request_id =
resolve_admin_usage_image_progress_by_request_id(state, &items).await?;
let active_candidate_state =
resolve_admin_usage_active_candidate_state(state, &items).await?;
return Ok(Some(build_admin_usage_active_requests_response(
&items,
&api_key_names,
state.has_auth_api_key_data_reader(),
&provider_key_names,
&image_progress_by_request_id,
&active_candidate_state.image_progress_by_request_id,
&active_candidate_state.state_overrides_by_request_id,
)));
}
Some("records")
@@ -642,6 +741,8 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
let client_family_filter = query_param_value(query, "client_family");
let hide_unknown_records = admin_usage_bool_query_param(query, "hide_unknown")
|| admin_usage_bool_query_param(query, "hide_unknown_records");
let include_total = admin_usage_include_total_query_param(query);
let total_only = admin_usage_bool_query_param(query, "total_only");
let limit = match admin_usage_parse_limit(query) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))),
@@ -665,13 +766,6 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
offset,
)));
};
let base_query = build_admin_usage_records_query(
created_from_unix_secs,
created_until_unix_secs,
query,
None,
None,
);
let active_search = search.as_deref().filter(|value| !value.trim().is_empty());
let active_username_filter = username_filter
.as_deref()
@@ -679,10 +773,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
let active_client_family_filter = client_family_filter
.as_deref()
.filter(|value| !value.trim().is_empty());
let (usage, total) = if hide_unknown_records
|| attempt_status_filter.is_some()
|| active_client_family_filter.is_some()
{
let mut base_query = build_admin_usage_records_query(
created_from_unix_secs,
created_until_unix_secs,
query,
None,
None,
);
base_query.client_family = active_client_family_filter.map(str::to_owned);
base_query.exclude_unknown_model_or_provider = hide_unknown_records;
let (usage, total, total_is_estimated) = if attempt_status_filter.is_some() {
let mut usage = state.list_usage_audits(&base_query).await?;
let user_ids: Vec<String> = usage
.iter()
@@ -719,18 +819,20 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
&attempt_flags_by_usage_id,
request_candidate_reader_available,
)
}) && admin_usage_matches_client_family(item, active_client_family_filter)
&& (!hide_unknown_records
|| !admin_usage_has_unknown_model_or_provider(item))
})
});
sort_usage_newest_first(&mut usage);
let total = usage.len();
let records = usage
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(records, total)
let records = if total_only {
Vec::new()
} else {
usage
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>()
};
(records, total, false)
} else if active_search.is_some() || active_username_filter.is_some() {
let keywords = active_search
.map(parse_admin_usage_search_keywords)
@@ -750,30 +852,53 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
None,
None,
);
let total = usize::try_from(
state
.count_usage_audits_by_keyword_search(&keyword_query)
.await?,
)
.unwrap_or(usize::MAX);
let paged_query = UsageAuditKeywordSearchQuery {
limit: Some(limit),
offset: Some(offset),
..keyword_query
};
(
state
.list_usage_audits_by_keyword_search(&paged_query)
.await?,
total,
)
} else {
let total = usize::try_from(state.count_usage_audits(&base_query).await?)
if total_only {
let total = usize::try_from(
state
.count_usage_audits_by_keyword_search(&keyword_query)
.await?,
)
.unwrap_or(usize::MAX);
let mut paged_query = base_query.clone();
paged_query.limit = Some(limit);
paged_query.offset = Some(offset);
(state.list_usage_audits(&paged_query).await?, total)
(Vec::new(), total, false)
} else {
let paged_query = UsageAuditKeywordSearchQuery {
limit: Some(limit),
offset: Some(offset),
..keyword_query.clone()
};
let records = state
.list_usage_audits_by_keyword_search(&paged_query)
.await?;
let total = if include_total {
usize::try_from(
state
.count_usage_audits_by_keyword_search(&keyword_query)
.await?,
)
.unwrap_or(usize::MAX)
} else {
admin_usage_fast_page_total(offset, limit, records.len())
};
(records, total, !include_total)
}
} else {
if total_only {
let total = usize::try_from(state.count_usage_audits(&base_query).await?)
.unwrap_or(usize::MAX);
(Vec::new(), total, false)
} else {
let mut paged_query = base_query.clone();
paged_query.limit = Some(limit);
paged_query.offset = Some(offset);
let records = state.list_usage_audits(&paged_query).await?;
let total = if include_total {
usize::try_from(state.count_usage_audits(&base_query).await?)
.unwrap_or(usize::MAX)
} else {
admin_usage_fast_page_total(offset, limit, records.len())
};
(records, total, !include_total)
}
};
let user_ids: Vec<String> = usage
@@ -801,6 +926,7 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
total,
limit,
offset,
total_is_estimated,
)));
}
_ => {}
@@ -808,3 +934,88 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response(
Ok(None)
}
#[cfg(test)]
mod tests {
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
};
use super::admin_usage_terminal_candidate_state_override;
fn sample_candidate(
candidate_index: i32,
status: RequestCandidateStatus,
status_code: Option<i32>,
latency_ms: Option<i32>,
error_message: Option<&str>,
) -> StoredRequestCandidate {
StoredRequestCandidate::new(
format!("candidate-{candidate_index}"),
"req-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
candidate_index,
0,
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("provider-key-1".to_string()),
status,
None,
false,
status_code,
None,
error_message.map(str::to_string),
latency_ms,
None,
None,
None,
1_000,
Some(1_000),
Some(10_210),
)
.expect("candidate should build")
}
#[test]
fn admin_usage_active_override_uses_current_terminal_candidate_latency() {
let candidate = sample_candidate(
0,
RequestCandidateStatus::Success,
Some(200),
Some(9_210),
None,
);
let payload =
admin_usage_terminal_candidate_state_override(&[candidate]).expect("override");
assert_eq!(payload["status"], "completed");
assert_eq!(payload["response_time_ms"], 9_210);
assert_eq!(
payload["response_time_updated_at"],
"1970-01-01T00:00:10.210+00:00"
);
}
#[test]
fn admin_usage_active_override_ignores_terminal_candidate_when_newer_attempt_is_live() {
let failed = sample_candidate(
0,
RequestCandidateStatus::Failed,
Some(503),
Some(1_000),
Some("first attempt failed"),
);
let mut streaming =
sample_candidate(1, RequestCandidateStatus::Streaming, None, None, None);
streaming.started_at_unix_ms = Some(10_500);
streaming.finished_at_unix_ms = None;
let payload = admin_usage_terminal_candidate_state_override(&[failed, streaming]);
assert!(payload.is_none());
}
}
@@ -94,7 +94,7 @@ pub(crate) async fn run_admin_provider_delete_task(
.map(|item| item.id.clone())
.collect::<Vec<_>>();
let key_ids = keys.iter().map(|item| item.id.clone()).collect::<Vec<_>>();
app.cleanup_deleted_provider_catalog_refs(&provider.id, &endpoint_ids, &key_ids)
app.cleanup_deleted_provider_catalog_refs(&provider.id, true, &endpoint_ids, &key_ids)
.await?;
task.stage = "deleting_models".to_string();
@@ -21,7 +21,10 @@ fn oauth_invalid_reason_is_account_level_block(reason: Option<&str>) -> bool {
snapshot.blocked
&& !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed"
"oauth_token_invalid"
| "oauth_token_expired"
| "oauth_expired"
| "oauth_refresh_failed"
)
}
@@ -13,6 +13,10 @@ pub(super) fn codex_looks_like_token_invalidated(message: Option<&str>) -> bool
admin_provider_quota_pure::codex_looks_like_token_invalidated(message)
}
pub(super) fn codex_looks_like_token_expired(message: Option<&str>) -> bool {
admin_provider_quota_pure::codex_looks_like_token_expired(message)
}
pub(super) fn codex_looks_like_workspace_deactivated(message: Option<&str>) -> bool {
admin_provider_quota_pure::codex_looks_like_workspace_deactivated(message)
}
@@ -3,7 +3,7 @@ mod parse;
mod plan;
use self::invalid::{
codex_build_invalid_state, codex_looks_like_token_invalidated,
codex_build_invalid_state, codex_looks_like_token_expired, codex_looks_like_token_invalidated,
codex_looks_like_workspace_deactivated, codex_soft_request_failure_reason,
codex_structured_invalid_reason,
};
@@ -275,6 +275,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
}
403 => {
let candidate_reason = if codex_looks_like_token_invalidated(err_msg.as_deref())
|| codex_looks_like_token_expired(err_msg.as_deref())
{
codex_structured_invalid_reason(403, err_msg.as_deref())
} else {
@@ -304,6 +304,7 @@ fn admin_pool_trimmed_string(value: Option<&Value>) -> Option<String> {
fn admin_pool_account_code_status_filter(code: &str) -> Option<&'static str> {
match code.trim().to_ascii_lowercase().as_str() {
"oauth_token_invalid" => Some("invalid"),
"oauth_token_expired" => Some("expired"),
"account_banned" | "account_suspended" => Some("account_banned"),
"account_disabled" => Some("account_disabled"),
"workspace_deactivated" => Some("workspace_deactivated"),
@@ -220,11 +220,17 @@ impl<'a> AdminAppState<'a> {
pub(crate) async fn cleanup_deleted_provider_catalog_refs(
&self,
provider_id: &str,
provider_deleted: bool,
endpoint_ids: &[String],
key_ids: &[String],
) -> Result<(), GatewayError> {
self.app
.cleanup_deleted_provider_catalog_refs(provider_id, endpoint_ids, key_ids)
.cleanup_deleted_provider_catalog_refs(
provider_id,
provider_deleted,
endpoint_ids,
key_ids,
)
.await
}
}
@@ -260,7 +260,7 @@ impl<'a> AdminAppState<'a> {
affected += 1;
}
}
self.cleanup_deleted_provider_catalog_refs(&provider.id, &[], &deleted_key_ids)
self.cleanup_deleted_provider_catalog_refs(&provider.id, false, &[], &deleted_key_ids)
.await?;
Ok(affected)
@@ -295,7 +295,7 @@ impl<'a> AdminAppState<'a> {
let deleted = self.delete_provider_catalog_key(&key.id).await?;
if deleted {
let deleted_key_ids = [key.id.clone()];
self.cleanup_deleted_provider_catalog_refs(&provider.id, &[], &deleted_key_ids)
self.cleanup_deleted_provider_catalog_refs(&provider.id, false, &[], &deleted_key_ids)
.await?;
}
Ok(deleted)
@@ -356,7 +356,7 @@ impl<'a> AdminAppState<'a> {
affected = affected.saturating_add(1);
}
}
self.cleanup_deleted_provider_catalog_refs(&provider.id, &[], &deleted_key_ids)
self.cleanup_deleted_provider_catalog_refs(&provider.id, false, &[], &deleted_key_ids)
.await?;
return Ok(Json(
+15 -7
View File
@@ -1797,13 +1797,21 @@ pub(crate) async fn proxy_request(
.all_candidates_skipped_for_reason(AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
|| local_execution_runtime_miss_context
.all_candidates_skipped_for_reason(LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON);
let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail(
control_decision,
local_execution_runtime_miss_diagnostic.as_ref(),
auth_api_key_concurrency_limited,
stream_request,
)
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
let local_execution_runtime_miss_detail = (!auth_api_key_concurrency_limited)
.then(|| {
local_execution_runtime_miss_context
.all_provider_request_body_build_failures_detail()
})
.flatten()
.or_else(|| {
local_execution_runtime_miss_detail(
control_decision,
local_execution_runtime_miss_diagnostic.as_ref(),
auth_api_key_concurrency_limited,
stream_request,
)
})
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
let local_execution_failure_path = if auth_api_key_concurrency_limited {
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED
} else {
@@ -4,11 +4,14 @@ use aether_ai_serving::UPSTREAM_IS_STREAM_KEY;
use aether_billing::{
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
};
use aether_data_contracts::repository::usage::{
StoredRequestUsageAudit, StoredUsageBreakdownSummaryRow, StoredUsageDailySummary,
UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageBreakdownGroupBy,
UsageBreakdownSummaryQuery, UsageCacheAffinityIntervalGroupBy, UsageCacheAffinityIntervalQuery,
UsageDashboardSummaryQuery,
use aether_data_contracts::repository::{
candidates::{RequestCandidateStatus, StoredRequestCandidate},
usage::{
StoredRequestUsageAudit, StoredUsageBreakdownSummaryRow, StoredUsageDailySummary,
UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageBreakdownGroupBy,
UsageBreakdownSummaryQuery, UsageCacheAffinityIntervalGroupBy,
UsageCacheAffinityIntervalQuery, UsageDashboardSummaryQuery,
},
};
use axum::{
body::Body,
@@ -17,7 +20,7 @@ use axum::{
Json,
};
use chrono::Utc;
use serde_json::json;
use serde_json::{json, Value};
use crate::GatewayError;
@@ -531,6 +534,8 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
"rate_multiplier": item.settlement_rate_multiplier(),
"response_time_ms": item.response_time_ms,
"first_byte_time_ms": item.first_byte_time_ms,
"updated_at": unix_secs_to_rfc3339(item.updated_at_unix_secs),
"response_time_updated_at": users_me_usage_response_time_updated_at(item),
"status_code": item.status_code,
"error_message": item.error_message,
"api_format": item.api_format,
@@ -573,6 +578,118 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
payload
}
fn users_me_usage_response_time_updated_at(item: &StoredRequestUsageAudit) -> Option<String> {
item.response_time_ms?;
if matches!(item.status.as_str(), "pending" | "streaming")
&& item.updated_at_unix_secs <= item.created_at_unix_ms
{
return None;
}
unix_secs_to_rfc3339(item.updated_at_unix_secs)
}
fn unix_millis_to_rfc3339(unix_ms: u64) -> Option<String> {
let secs = i64::try_from(unix_ms / 1_000).ok()?;
let nanos = u32::try_from(unix_ms % 1_000)
.ok()?
.saturating_mul(1_000_000);
chrono::DateTime::<Utc>::from_timestamp(secs, nanos).map(|timestamp| timestamp.to_rfc3339())
}
fn users_me_usage_current_candidate(
candidates: &[StoredRequestCandidate],
) -> Option<&StoredRequestCandidate> {
candidates
.iter()
.filter(|candidate| {
!matches!(
candidate.status,
RequestCandidateStatus::Available
| RequestCandidateStatus::Unused
| RequestCandidateStatus::Skipped
)
})
.max_by_key(|candidate| {
(
candidate.candidate_index,
candidate.retry_index,
candidate
.started_at_unix_ms
.or(candidate.finished_at_unix_ms)
.unwrap_or(candidate.created_at_unix_ms),
)
})
}
fn users_me_usage_terminal_candidate_state_override(
candidates: &[StoredRequestCandidate],
) -> Option<Value> {
let candidate = users_me_usage_current_candidate(candidates)?;
let status = match candidate.status {
RequestCandidateStatus::Success => "completed",
RequestCandidateStatus::Failed => "failed",
RequestCandidateStatus::Cancelled => "cancelled",
_ => return None,
};
let latency_ms = candidate.latency_ms.or_else(|| {
Some(
candidate
.finished_at_unix_ms?
.saturating_sub(candidate.started_at_unix_ms?),
)
});
let mut payload = json!({ "status": status });
if let Some(latency_ms) = latency_ms {
payload["response_time_ms"] = json!(latency_ms);
if let Some(response_time_updated_at) = candidate
.finished_at_unix_ms
.or_else(|| {
candidate
.started_at_unix_ms
.map(|started_at| started_at.saturating_add(latency_ms))
})
.and_then(unix_millis_to_rfc3339)
{
payload["response_time_updated_at"] = json!(response_time_updated_at);
}
}
if let Some(status_code) = candidate.status_code {
payload["status_code"] = json!(status_code);
}
if let Some(error_message) = candidate.error_message.as_ref() {
payload["error_message"] = json!(error_message);
}
Some(payload)
}
async fn resolve_users_me_usage_active_state_overrides_by_request_id(
state: &AppState,
items: &[StoredRequestUsageAudit],
) -> Result<BTreeMap<String, Value>, GatewayError> {
if !state.has_request_candidate_data_reader() || items.is_empty() {
return Ok(BTreeMap::new());
}
let active_request_ids = items
.iter()
.filter(|item| matches!(item.status.as_str(), "pending" | "streaming"))
.map(|item| item.request_id.clone())
.collect::<BTreeSet<_>>();
let mut overrides = BTreeMap::new();
for request_id in active_request_ids {
let candidates = state
.read_request_candidates_by_request_id(&request_id)
.await?;
if let Some(override_payload) =
users_me_usage_terminal_candidate_state_override(&candidates)
{
overrides.insert(request_id, override_payload);
}
}
Ok(overrides)
}
fn users_me_usage_is_failed(item: &StoredRequestUsageAudit) -> bool {
let has_failure_signal = item.status_code.is_some_and(|value| value >= 400)
|| item
@@ -934,6 +1051,8 @@ pub(super) async fn handle_users_me_usage_get(
provider_name: None,
model: None,
api_format: None,
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
@@ -988,6 +1107,8 @@ pub(super) async fn handle_users_me_usage_get(
provider_name: None,
model: None,
api_format: None,
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
@@ -1015,6 +1136,8 @@ pub(super) async fn handle_users_me_usage_get(
provider_name: None,
model: None,
api_format: None,
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: None,
exclude_status_codes: Vec::new(),
is_stream: None,
@@ -1152,6 +1275,8 @@ pub(super) async fn handle_users_me_usage_active_get(
provider_name: None,
model: None,
api_format: None,
client_family: None,
exclude_unknown_model_or_provider: false,
statuses: Some(vec!["pending".to_string(), "streaming".to_string()]),
exclude_status_codes: Vec::new(),
is_stream: None,
@@ -1181,11 +1306,35 @@ pub(super) async fn handle_users_me_usage_active_get(
.filter(|item| !users_me_usage_is_failed(item))
.collect::<Vec<_>>()
};
let active_state_overrides =
match resolve_users_me_usage_active_state_overrides_by_request_id(state, &items).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user active usage candidate lookup failed: {err:?}"),
false,
);
}
};
Json(json!({
"requests": items
.iter()
.map(build_users_me_usage_active_payload)
.map(|item| {
let mut payload = build_users_me_usage_active_payload(item);
if let (Some(payload), Some(overrides)) = (
payload.as_object_mut(),
active_state_overrides
.get(&item.request_id)
.and_then(Value::as_object),
) {
for (key, value) in overrides {
payload.insert(key.clone(), value.clone());
}
}
payload
})
.collect::<Vec<_>>(),
}))
.into_response()
@@ -1377,13 +1526,16 @@ async fn build_usage_heatmap_summaries(
mod tests {
use std::collections::BTreeMap;
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
use aether_data_contracts::repository::{
candidates::{RequestCandidateStatus, StoredRequestCandidate},
usage::StoredRequestUsageAudit,
};
use serde_json::json;
use super::{
build_users_me_usage_active_payload, build_users_me_usage_record_payload,
users_me_usage_client_is_stream, users_me_usage_is_failed,
users_me_usage_upstream_is_stream,
users_me_usage_terminal_candidate_state_override, users_me_usage_upstream_is_stream,
};
fn sample_usage(status: &str) -> StoredRequestUsageAudit {
@@ -1428,6 +1580,41 @@ mod tests {
.expect("usage should build")
}
fn sample_candidate(
status: RequestCandidateStatus,
status_code: Option<i32>,
latency_ms: Option<i32>,
error_message: Option<&str>,
) -> StoredRequestCandidate {
StoredRequestCandidate::new(
"candidate-1".to_string(),
"req-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
0,
0,
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("provider-key-1".to_string()),
status,
None,
false,
status_code,
None,
error_message.map(str::to_string),
latency_ms,
None,
None,
None,
1_000,
Some(1_000),
Some(10_210),
)
.expect("candidate should build")
}
#[test]
fn user_usage_record_payload_rehydrates_cache_creation_total_from_classified_fields() {
let item = StoredRequestUsageAudit {
@@ -1460,6 +1647,45 @@ mod tests {
assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6);
}
#[test]
fn user_usage_active_override_uses_terminal_candidate_latency() {
let candidate = sample_candidate(
RequestCandidateStatus::Success,
Some(200),
Some(9_210),
None,
);
let payload =
users_me_usage_terminal_candidate_state_override(&[candidate]).expect("override");
assert_eq!(payload["status"], "completed");
assert_eq!(payload["response_time_ms"], 9_210);
assert_eq!(payload["status_code"], 200);
assert_eq!(
payload["response_time_updated_at"],
"1970-01-01T00:00:10.210+00:00"
);
}
#[test]
fn user_usage_active_override_ignores_terminal_candidate_when_newer_attempt_is_live() {
let failed = sample_candidate(
RequestCandidateStatus::Failed,
Some(503),
Some(1_000),
Some("first attempt failed"),
);
let mut streaming = sample_candidate(RequestCandidateStatus::Streaming, None, None, None);
streaming.candidate_index = 1;
streaming.started_at_unix_ms = Some(10_500);
streaming.finished_at_unix_ms = None;
let payload = users_me_usage_terminal_candidate_state_override(&[failed, streaming]);
assert!(payload.is_none());
}
#[test]
fn user_usage_payload_keeps_claude_effective_input_when_cache_read_is_large() {
let item = StoredRequestUsageAudit {
@@ -265,14 +265,16 @@ fn build_provider_key_oauth_status_snapshot(key: &StoredProviderCatalogKey) -> V
if let Some(reason) =
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX)
{
let (code, label) =
admin_provider_status_pure::oauth_token_snapshot_status_parts(reason.as_str());
return json!({
"code": "invalid",
"label": "已失效",
"code": code,
"label": label,
"reason": reason,
"expires_at": expires_at_unix_secs,
"invalid_at": invalid_at_unix_secs,
"source": "oauth_invalid",
"requires_reauth": true,
"requires_reauth": code == "invalid",
"expiring_soon": false,
});
}
@@ -352,7 +352,8 @@ fn normalize_chat_pii_redaction_feature_settings(
fn normalize_chat_pii_redaction_feature_object(
feature: &mut Map<String, Value>,
) -> Result<(), String> {
for key in ["enabled", "inject_model_instruction"] {
feature.remove("inject_model_instruction");
for key in ["enabled"] {
if let Some(value) = feature.get(key) {
if !value.is_boolean() {
return Err(format!("chat_pii_redaction.{key} 必须是布尔值"));
@@ -450,7 +451,7 @@ mod tests {
fn user_self_feature_update_preserves_notification_push_permission() {
let normalized = normalize_user_self_feature_settings_update(
Some(json!({
"chat_pii_redaction": {"enabled": true, "inject_model_instruction": false},
"chat_pii_redaction": {"enabled": true},
"notification_push_service": {"enabled": false}
})),
Some(json!({
@@ -61,11 +61,8 @@ pub(crate) fn classify_local_failover(
}
if input.status_code >= 400
&& input.response_text.is_some_and(|text| {
policy
.error_stop_patterns
.iter()
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
&& policy.error_stop_patterns.iter().any(|rule| {
local_failover_regex_rule_matches(rule, input.response_text, input.status_code)
})
{
return LocalFailoverClassification::StopErrorPattern;
@@ -76,7 +73,7 @@ pub(crate) fn classify_local_failover(
policy
.success_failover_patterns
.iter()
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
.any(|rule| local_failover_regex_rule_matches(rule, Some(text), input.status_code))
})
{
return LocalFailoverClassification::RetrySuccessPattern;
@@ -190,14 +187,23 @@ fn first_non_empty_json_text(
fn local_failover_regex_rule_matches(
rule: &LocalFailoverRegexRule,
response_text: &str,
response_text: Option<&str>,
status_code: u16,
) -> bool {
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
return false;
}
Regex::new(&rule.pattern)
let pattern = rule.pattern.trim();
if pattern.is_empty() {
return !rule.status_codes.is_empty();
}
let Some(response_text) = response_text else {
return false;
};
Regex::new(pattern)
.ok()
.is_some_and(|regex| regex.is_match(response_text))
}
@@ -260,6 +266,50 @@ mod tests {
);
}
#[test]
fn classifier_detects_error_stop_pattern_without_status_codes_on_any_error_status() {
let policy = LocalFailoverPolicy {
error_stop_patterns: vec![LocalFailoverRegexRule {
pattern: "content_policy_violation".to_string(),
status_codes: BTreeSet::new(),
}],
..LocalFailoverPolicy::default()
};
for status_code in [400, 429, 503] {
assert_eq!(
classify_local_failover(
&policy,
LocalFailoverInput::new(
status_code,
Some("{\"error\":\"content_policy_violation\"}")
)
),
LocalFailoverClassification::StopErrorPattern
);
}
}
#[test]
fn classifier_detects_status_only_error_stop_rule_without_response_text() {
let policy = LocalFailoverPolicy {
error_stop_patterns: vec![LocalFailoverRegexRule {
pattern: String::new(),
status_codes: [429].into_iter().collect(),
}],
..LocalFailoverPolicy::default()
};
assert_eq!(
classify_local_failover(&policy, LocalFailoverInput::new(429, None)),
LocalFailoverClassification::StopErrorPattern
);
assert_eq!(
classify_local_failover(&policy, LocalFailoverInput::new(503, None)),
LocalFailoverClassification::RetryUpstreamFailure
);
}
#[test]
fn classifier_detects_success_continue_status_code() {
let policy = LocalFailoverPolicy {
@@ -2031,7 +2031,7 @@ mod tests {
}
#[tokio::test]
async fn oauth_invalidation_marks_codex_key_invalid() {
async fn oauth_invalidation_marks_codex_key_expired() {
let state = codex_state();
let plan = sample_codex_plan();
@@ -2069,7 +2069,7 @@ mod tests {
.and_then(|value| value.get("oauth"))
.and_then(|value| value.get("code"))
.and_then(Value::as_str),
Some("invalid")
Some("expired")
);
}
+19 -30
View File
@@ -25,27 +25,8 @@ pub(crate) struct LocalFailoverRegexRule {
pub(crate) async fn resolve_local_failover_policy(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
_report_context: Option<&serde_json::Value>,
) -> LocalFailoverPolicy {
if let Some(policy) = local_failover_policy_from_report_context(report_context) {
debug!(
event_name = "local_failover_policy_loaded",
log_type = "debug",
request_id = %plan.request_id,
provider_id = %plan.provider_id,
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
source = "report_context",
max_retries = ?policy.max_retries,
stop_status_code_count = policy.stop_status_codes.len(),
continue_status_code_count = policy.continue_status_codes.len(),
success_failover_pattern_count = policy.success_failover_patterns.len(),
error_stop_pattern_count = policy.error_stop_patterns.len(),
"gateway loaded local failover policy from report context"
);
return policy;
}
let transport = match state
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
.await
@@ -201,31 +182,39 @@ fn parse_regex_rules(
rules: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Vec<LocalFailoverRegexRule> {
let allow_status_only = key == "error_stop_patterns";
rules
.get(key)
.and_then(Value::as_array)
.into_iter()
.flat_map(|items| items.iter())
.filter_map(parse_regex_rule)
.filter_map(|value| parse_regex_rule(value, allow_status_only))
.collect()
}
fn parse_regex_rule(value: &serde_json::Value) -> Option<LocalFailoverRegexRule> {
fn parse_regex_rule(
value: &serde_json::Value,
allow_status_only: bool,
) -> Option<LocalFailoverRegexRule> {
let object = value.as_object()?;
let pattern = object
.get("pattern")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
.unwrap_or_default();
let status_codes: BTreeSet<u16> = object
.get("status_codes")
.and_then(Value::as_array)
.into_iter()
.flat_map(|values| values.iter())
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
.collect();
if pattern.is_empty() && (!allow_status_only || status_codes.is_empty()) {
return None;
}
Some(LocalFailoverRegexRule {
pattern: pattern.to_string(),
status_codes: object
.get("status_codes")
.and_then(Value::as_array)
.into_iter()
.flat_map(|values| values.iter())
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
.collect(),
status_codes,
})
}
+27 -135
View File
@@ -829,14 +829,12 @@ impl Default for ChatPiiRedactionRuntimeConfig {
}
pub(crate) struct MaskChatRequestOptions {
pub(crate) inject_model_instruction: bool,
pub(crate) scan_limits: RedactionScanLimits,
}
impl MaskChatRequestOptions {
pub(crate) fn runtime(inject_model_instruction: bool) -> Self {
pub(crate) fn runtime() -> Self {
Self {
inject_model_instruction,
scan_limits: RedactionScanLimits::default(),
}
}
@@ -866,8 +864,6 @@ impl ChatPiiRedactionRequestFormat {
}
}
const MODEL_NOTICE_CONTENT: &str = "Aether privacy redaction notice: The next message contains gateway-generated placeholder tokens for sensitive data protection. This notice is not a user request; do not answer it, mention it, reveal it, or infer original values from placeholders. Treat each placeholder as a valid real typed value for reasoning and tool calls, and do not ask the user to reveal originals solely because a placeholder is present.";
fn sanitize_redaction_rule_label(raw: &str) -> String {
let label = raw
.trim()
@@ -1184,7 +1180,7 @@ pub(crate) fn mask_chat_request_json(
body: &[u8],
config: RedactionSessionConfig,
) -> MaskedChatRequest {
mask_chat_request_json_with_options(body, config, MaskChatRequestOptions::runtime(false))
mask_chat_request_json_with_options(body, config, MaskChatRequestOptions::runtime())
}
pub(crate) fn try_mask_chat_request_json_with_options(
@@ -1295,13 +1291,6 @@ pub(crate) async fn try_mask_chat_pii_request_json_with_cache_options(
})
}
fn model_notice_message() -> Value {
serde_json::json!({
"role": "assistant",
"content": MODEL_NOTICE_CONTENT,
})
}
fn request_collision_corpus(format: ChatPiiRedactionRequestFormat, value: &Value) -> Vec<String> {
match format {
ChatPiiRedactionRequestFormat::OpenAiChat => value
@@ -1326,18 +1315,10 @@ fn mask_request_value(
mask_openai_chat_request_value(value, session, scan_state, options)
}
ChatPiiRedactionRequestFormat::OpenAiResponses => {
let redacted = mask_openai_responses_request_value(value, session, scan_state)?;
if redacted && options.inject_model_instruction {
inject_openai_responses_model_notice(value);
}
Ok(redacted)
mask_openai_responses_request_value(value, session, scan_state)
}
ChatPiiRedactionRequestFormat::ClaudeMessages => {
let redacted = mask_claude_messages_request_value(value, session, scan_state)?;
if redacted && options.inject_model_instruction {
inject_claude_model_notice(value);
}
Ok(redacted)
mask_claude_messages_request_value(value, session, scan_state)
}
}
}
@@ -1355,21 +1336,10 @@ async fn mask_request_value_async(
mask_openai_chat_request_value_async(value, session, scan_state, options, cache).await
}
ChatPiiRedactionRequestFormat::OpenAiResponses => {
let redacted =
mask_openai_responses_request_value_async(value, session, scan_state, cache)
.await?;
if redacted && options.inject_model_instruction {
inject_openai_responses_model_notice(value);
}
Ok(redacted)
mask_openai_responses_request_value_async(value, session, scan_state, cache).await
}
ChatPiiRedactionRequestFormat::ClaudeMessages => {
let redacted =
mask_claude_messages_request_value_async(value, session, scan_state, cache).await?;
if redacted && options.inject_model_instruction {
inject_claude_model_notice(value);
}
Ok(redacted)
mask_claude_messages_request_value_async(value, session, scan_state, cache).await
}
}
}
@@ -1385,16 +1355,10 @@ fn mask_openai_chat_request_value(
};
let mut redacted = false;
let mut notice_inserted = false;
let mut index = 0;
while index < messages.len() {
let message_redacted = mask_chat_message_value(&mut messages[index], session, scan_state)?;
redacted |= message_redacted;
if options.inject_model_instruction && message_redacted && !notice_inserted {
messages.insert(index, model_notice_message());
notice_inserted = true;
index += 1;
}
index += 1;
}
Ok(redacted)
@@ -1412,17 +1376,11 @@ async fn mask_openai_chat_request_value_async(
};
let mut redacted = false;
let mut notice_inserted = false;
let mut index = 0;
while index < messages.len() {
let message_redacted =
mask_chat_message_value_async(&mut messages[index], session, scan_state, cache).await?;
redacted |= message_redacted;
if options.inject_model_instruction && message_redacted && !notice_inserted {
messages.insert(index, model_notice_message());
notice_inserted = true;
index += 1;
}
index += 1;
}
Ok(redacted)
@@ -2150,56 +2108,6 @@ async fn mask_json_string_async(
Ok(true)
}
fn inject_openai_responses_model_notice(value: &mut Value) {
let Some(request) = value.as_object_mut() else {
return;
};
match request.get_mut("instructions") {
Some(Value::String(instructions)) => prepend_model_notice(instructions),
Some(_) => {}
None => {
request.insert(
"instructions".to_string(),
Value::String(MODEL_NOTICE_CONTENT.to_string()),
);
}
}
}
fn inject_claude_model_notice(value: &mut Value) {
let Some(request) = value.as_object_mut() else {
return;
};
match request.get_mut("system") {
Some(Value::String(system)) => prepend_model_notice(system),
Some(Value::Array(parts)) => parts.insert(
0,
serde_json::json!({
"type": "text",
"text": MODEL_NOTICE_CONTENT,
}),
),
Some(_) => {}
None => {
request.insert(
"system".to_string(),
Value::String(MODEL_NOTICE_CONTENT.to_string()),
);
}
}
}
fn prepend_model_notice(text: &mut String) {
if text.contains(MODEL_NOTICE_CONTENT) {
return;
}
if text.trim().is_empty() {
*text = MODEL_NOTICE_CONTENT.to_string();
} else {
*text = format!("{MODEL_NOTICE_CONTENT}\n\n{text}");
}
}
pub(crate) struct RestoredSyncResponseBody {
pub(crate) body: Vec<u8>,
pub(crate) restored: bool,
@@ -4408,7 +4316,7 @@ mod tests {
&raw,
ChatPiiRedactionRequestFormat::ClaudeMessages,
test_config(),
MaskChatRequestOptions::runtime(true),
MaskChatRequestOptions::runtime(),
)
.expect("claude messages request should mask");
@@ -4417,11 +4325,7 @@ mod tests {
let masked_json: serde_json::Value =
serde_json::from_slice(&masked.body).expect("masked request should stay valid JSON");
assert_eq!(masked_json["metadata"]["owner"], "metadata@example.com");
assert!(masked_json["system"][0]["text"]
.as_str()
.expect("notice should remain a string")
.contains("Aether privacy redaction notice"));
assert!(!masked_json["system"][1]["text"]
assert!(!masked_json["system"][0]["text"]
.as_str()
.expect("system text should remain a string")
.contains("alice@example.com"));
@@ -4454,7 +4358,7 @@ mod tests {
&raw,
ChatPiiRedactionRequestFormat::OpenAiChat,
test_config(),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
)
.expect("chat request should mask");
@@ -4497,7 +4401,7 @@ mod tests {
&raw,
ChatPiiRedactionRequestFormat::OpenAiResponses,
test_config(),
MaskChatRequestOptions::runtime(true),
MaskChatRequestOptions::runtime(),
)
.expect("responses request should mask");
@@ -4509,7 +4413,6 @@ mod tests {
let instructions = masked_json["instructions"]
.as_str()
.expect("instructions should remain a string");
assert!(instructions.contains("Aether privacy redaction notice"));
assert!(!instructions.contains("alice@example.com"));
assert!(!masked_json["input"][0]["content"][0]["text"]
.as_str()
@@ -4995,7 +4898,7 @@ mod tests {
let masked = mask_chat_request_json_with_options(
&serde_json::to_vec(&request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
);
let masked_json: serde_json::Value =
@@ -5032,7 +4935,7 @@ mod tests {
let masked = mask_chat_request_json_with_options(
&serde_json::to_vec(&request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(true),
MaskChatRequestOptions::runtime(),
);
assert!(!masked.redacted);
@@ -5041,9 +4944,6 @@ mod tests {
assert_eq!(masked_json, request);
assert!(masked_json.to_string().contains("alice@example.com"));
assert!(!masked_json.to_string().contains("<AETHER:"));
assert!(!masked_json
.to_string()
.contains("Aether privacy redaction notice"));
}
#[test]
@@ -5105,7 +5005,7 @@ mod tests {
}
#[test]
fn proxy_pii_redaction_provider_bound_request_uses_sentinels_and_inserts_safe_notice() {
fn proxy_pii_redaction_provider_bound_request_uses_sentinels_without_prompt_notice() {
let config = ChatPiiRedactionRuntimeConfig::default();
let request = json!({
"model": "gpt-5",
@@ -5119,7 +5019,7 @@ mod tests {
let masked = mask_chat_request_json_with_options(
&serde_json::to_vec(&request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(true),
MaskChatRequestOptions::runtime(),
);
assert!(masked.redacted);
@@ -5128,26 +5028,18 @@ mod tests {
let messages = masked_json["messages"]
.as_array()
.expect("messages should be an array");
assert_eq!(messages.len(), 4);
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[1]["role"], "assistant");
assert!(messages[1..]
.iter()
.all(|message| message["role"].as_str() != Some("system")));
let notice = messages[1]["content"]
.as_str()
.expect("notice should be text");
assert!(notice.contains("not a user request"));
assert!(notice.contains("do not answer"));
assert!(notice.contains("do not answer it, mention it"));
assert!(!notice.contains("alice@example.com"));
assert_eq!(messages[2]["role"], "user");
let content = messages[2]["content"]
assert_eq!(messages[1]["role"], "user");
let content = messages[1]["content"]
.as_str()
.expect("user content should be text");
assert!(!content.contains("alice@example.com"));
assert!(content.contains("<AETHER:EMAIL:"));
assert_eq!(messages[3]["role"], "assistant");
assert_eq!(messages[2]["role"], "assistant");
}
#[test]
@@ -5239,7 +5131,7 @@ mod tests {
let large_err = try_mask_chat_request_json_with_options(
&serde_json::to_vec(&large_request).expect("request should serialize"),
test_config(),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
)
.expect_err("oversized scan should fail closed");
assert_eq!(
@@ -5261,7 +5153,7 @@ mod tests {
let dense_err = try_mask_chat_request_json_with_options(
&serde_json::to_vec(&dense_request).expect("request should serialize"),
test_config(),
MaskChatRequestOptions::runtime(false).with_scan_limits(RedactionScanLimits {
MaskChatRequestOptions::runtime().with_scan_limits(RedactionScanLimits {
max_scanned_text_bytes: 1024,
max_detections: 1,
}),
@@ -5296,7 +5188,7 @@ mod tests {
let first_masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&first_request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5315,7 +5207,7 @@ mod tests {
let second_masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&second_request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 899),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5356,7 +5248,7 @@ mod tests {
let rolled_masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&second_request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 900),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5420,7 +5312,7 @@ mod tests {
let first_masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&first_request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5454,7 +5346,7 @@ mod tests {
let second_masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&colliding_request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 899),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5525,7 +5417,7 @@ mod tests {
let masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -5570,7 +5462,7 @@ mod tests {
let masked = try_mask_chat_request_json_with_cache_options(
&serde_json::to_vec(&request).expect("request should serialize"),
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
MaskChatRequestOptions::runtime(false),
MaskChatRequestOptions::runtime(),
Some(&cache),
)
.await
@@ -376,9 +376,6 @@ fn oauth_invalid_reason_blocks_scheduling(
now_unix_secs: u64,
) -> bool {
let trimmed_reason = invalid_reason.trim();
if oauth_invalid_reason_has_tag(trimmed_reason, "[OAUTH_EXPIRED]") {
return true;
}
let account_state = admin_provider_status_pure::resolve_pool_account_state(
Some(provider_type),
@@ -433,6 +430,7 @@ fn oauth_account_state_code_is_hard_block(code: &str) -> bool {
| "account_forbidden"
| "account_blocked"
| "account_verification"
| "oauth_token_invalid"
)
}
@@ -2064,7 +2064,7 @@ async fn keeps_codex_candidate_selectable_when_oauth_token_is_expired() {
let mut key = sample_key("key-codex", "provider-codex", Some(10));
key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key.oauth_invalid_reason = Some("Codex Token 无效或已过期".to_string());
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
key
}],
));
+26 -1
View File
@@ -564,11 +564,17 @@ impl AppState {
pub(crate) async fn cleanup_deleted_provider_catalog_refs(
&self,
provider_id: &str,
provider_deleted: bool,
endpoint_ids: &[String],
key_ids: &[String],
) -> Result<(), GatewayError> {
self.data
.cleanup_deleted_provider_catalog_refs(provider_id, endpoint_ids, key_ids)
.cleanup_deleted_provider_catalog_refs(
provider_id,
provider_deleted,
endpoint_ids,
key_ids,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
for key_id in key_ids {
@@ -770,6 +776,25 @@ impl AppState {
tasks.insert(task.task_id.clone(), task);
}
pub(crate) fn reserve_provider_delete_task(
&self,
task: LocalProviderDeleteTaskState,
) -> LocalProviderDeleteTaskState {
let mut tasks = self
.provider_delete_tasks
.lock()
.expect("provider delete tasks cache should lock");
if let Some(existing) = tasks
.values()
.find(|existing| existing.provider_id == task.provider_id && existing.is_active())
.cloned()
{
return existing;
}
tasks.insert(task.task_id.clone(), task.clone());
task
}
pub(crate) fn get_provider_delete_task(
&self,
task_id: &str,
+10 -4
View File
@@ -171,7 +171,10 @@ fn oauth_invalid_reason_is_account_block(reason: Option<&str>) -> bool {
snapshot.blocked
&& !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed"
"oauth_token_invalid"
| "oauth_token_expired"
| "oauth_expired"
| "oauth_refresh_failed"
)
}
@@ -356,14 +359,16 @@ fn build_oauth_status_snapshot_value(key: &StoredProviderCatalogKey) -> Value {
let invalid_reason = trimmed_reason(key.oauth_invalid_reason.as_deref());
if let Some(reason) = tagged_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX) {
let (code, label) =
aether_admin::provider::status::oauth_token_snapshot_status_parts(reason.as_str());
return json!({
"code": "invalid",
"label": "已失效",
"code": code,
"label": label,
"reason": reason,
"expires_at": expires_at_unix_secs,
"invalid_at": invalid_at_unix_secs,
"source": "oauth_invalid",
"requires_reauth": true,
"requires_reauth": code == "invalid",
"expiring_soon": false,
});
}
@@ -1291,6 +1296,7 @@ impl AppState {
let deleted_key_ids = [key_id.to_string()];
self.cleanup_deleted_provider_catalog_refs(
&transport.provider.id,
false,
&[],
&deleted_key_ids,
)
+6
View File
@@ -11,6 +11,12 @@ pub(crate) struct LocalProviderDeleteTaskState {
pub message: String,
}
impl LocalProviderDeleteTaskState {
pub(crate) fn is_active(&self) -> bool {
matches!(self.status.as_str(), "pending" | "running")
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum LocalMutationOutcome<T> {
Applied(T),
+30 -12
View File
@@ -46,6 +46,7 @@ pub(crate) const TASK_KEY_STATS_HOURLY_AGG: &str = "maintenance.stats.hourly.agg
pub(crate) const TASK_KEY_USAGE_SYNC_REPORT: &str = "usage.sync.report";
pub(crate) const TASK_KEY_PROVIDER_OAUTH_ACCOUNT_REFRESH: &str = "provider.oauth.account.refresh";
pub(crate) const TASK_KEY_PROVIDER_BALANCE_REFRESH: &str = "provider.ops.balance.refresh";
const PROVIDER_DELETE_LOCK_TTL_SECS: u64 = 60 * 60 * 6;
const RETRY_ONCE: RetryPolicy = RetryPolicy { max_attempts: 1 };
@@ -501,17 +502,23 @@ pub(crate) async fn submit_provider_delete_task(
};
let task_id = Uuid::new_v4().simple().to_string()[..16].to_string();
state.put_provider_delete_task(crate::LocalProviderDeleteTaskState {
task_id: task_id.clone(),
provider_id: provider.id.clone(),
status: "pending".to_string(),
stage: "queued".to_string(),
total_keys: 0,
deleted_keys: 0,
total_endpoints: 0,
deleted_endpoints: 0,
message: "delete task submitted".to_string(),
});
let reserved =
state
.as_ref()
.reserve_provider_delete_task(crate::LocalProviderDeleteTaskState {
task_id: task_id.clone(),
provider_id: provider.id.clone(),
status: "pending".to_string(),
stage: "queued".to_string(),
total_keys: 0,
deleted_keys: 0,
total_endpoints: 0,
deleted_endpoints: 0,
message: "delete task submitted".to_string(),
});
if reserved.task_id != task_id {
return Ok(Some(reserved.task_id));
}
let app = state.cloned_app();
let provider_id = provider.id.clone();
@@ -555,7 +562,7 @@ pub(crate) async fn submit_provider_delete_task(
spawn_named("task-runtime-provider-delete", async move {
let lock_key = format!("task_runtime:lock:{TASK_KEY_PROVIDER_DELETE}:{provider_id}");
let lock_ttl = std::time::Duration::from_secs(60 * 15);
let lock_ttl = std::time::Duration::from_secs(PROVIDER_DELETE_LOCK_TTL_SECS);
let lock = app
.runtime_state
.lock_try_acquire(&lock_key, app.tunnel.local_instance_id(), lock_ttl)
@@ -563,6 +570,17 @@ pub(crate) async fn submit_provider_delete_task(
.ok()
.flatten();
if lock.is_none() {
app.put_provider_delete_task(crate::LocalProviderDeleteTaskState {
task_id: run_id.clone(),
provider_id: provider_id.clone(),
status: "failed".to_string(),
stage: "skipped".to_string(),
total_keys: 0,
deleted_keys: 0,
total_endpoints: 0,
deleted_endpoints: 0,
message: "provider delete skipped: another node is running this task".to_string(),
});
let _ = update_run_status(
&app,
&run_id,
@@ -389,6 +389,9 @@ async fn gateway_executes_openai_responses_compact_openai_family_upstream_stream
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
let created_at = response_json["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
response_json,
json!({
@@ -396,6 +399,9 @@ async fn gateway_executes_openai_responses_compact_openai_family_upstream_stream
"object": "response",
"model": "gpt-5",
"status": "completed",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello Compact",
"output": [{
"type": "message",
"id": "resp_compact_openai_family_123_msg",
@@ -389,6 +389,9 @@ async fn gateway_executes_openai_responses_cross_format_upstream_stream_via_loca
assert_eq!(response_status, StatusCode::OK);
let response_json: serde_json::Value =
serde_json::from_str(&response_body).expect("body should parse");
let created_at = response_json["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
response_json,
json!({
@@ -396,6 +399,9 @@ async fn gateway_executes_openai_responses_cross_format_upstream_stream_via_loca
"object": "response",
"status": "completed",
"model": "gemini-2.5-pro-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello Gemini CLI",
"output": [{
"type": "message",
"id": "upstream-cli-stream-123_msg",
@@ -842,6 +848,9 @@ async fn gateway_executes_openai_responses_cross_format_function_call_upstream_s
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
let created_at = response_json["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
response_json,
json!({
@@ -849,6 +858,9 @@ async fn gateway_executes_openai_responses_cross_format_function_call_upstream_s
"object": "response",
"status": "completed",
"model": "gemini-2.5-pro-upstream",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Need a tool.",
"output": [
{
"type": "message",
@@ -1412,6 +1424,9 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
let created_at = response_json["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
response_json,
json!({
@@ -1419,6 +1434,9 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str
"object": "response",
"status": "completed",
"model": "claude-sonnet-4-5",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello Antigravity",
"output": [{
"type": "message",
"id": "resp-local-stream_msg",
@@ -405,6 +405,9 @@ async fn gateway_executes_openai_responses_sync_upstream_stream_via_local_finali
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
let created_at = response_json["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
response_json,
json!({
@@ -412,6 +415,9 @@ async fn gateway_executes_openai_responses_sync_upstream_stream_via_local_finali
"object": "response",
"model": "gpt-5-upstream",
"status": "completed",
"created_at": created_at,
"completed_at": created_at,
"output_text": "Hello",
"output": [{
"type": "message",
"id": "resp_stream_001_msg",
@@ -112,7 +112,6 @@ fn auth_repository_with_redaction_feature_settings() -> Arc<InMemoryAuthApiKeySn
Some(json!({
"chat_pii_redaction": {
"enabled": true,
"inject_model_instruction": true,
}
})),
)]),
@@ -468,9 +468,38 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let response_body =
strip_sse_keepalive_comments(&response.text().await.expect("body should read"));
let data_line = response_body
.lines()
.find_map(|line| line.strip_prefix("data: "))
.expect("completed event data should exist");
let completed_event: serde_json::Value =
serde_json::from_str(data_line).expect("completed event should parse");
let created_at = completed_event["response"]["created_at"]
.as_i64()
.expect("created_at should be a unix timestamp");
assert_eq!(
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_codex_cli_stream_local_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"
completed_event,
json!({
"type": "response.completed",
"response": {
"id": "resp_codex_cli_stream_local_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
},
"output": [],
"created_at": created_at,
"completed_at": created_at,
"output_text": ""
}
})
);
let seen_refresh_request = seen_refresh
@@ -112,7 +112,6 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
Some(json!({
"chat_pii_redaction": {
"enabled": true,
"inject_model_instruction": true,
}
})),
)]),
@@ -363,13 +362,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
let provider_body_text = serde_json::to_string(&seen.body).expect("body should serialize");
assert!(!provider_body_text.contains("alice@example.com"));
assert!(provider_body_text.contains("<AETHER:EMAIL:"));
assert_eq!(seen.body["messages"][0]["role"], "assistant");
let notice = seen.body["messages"][0]["content"]
.as_str()
.expect("notice should be text");
assert!(notice.contains("not a user request"));
assert!(notice.contains("do not answer"));
assert_eq!(seen.body["messages"][1]["role"], "user");
assert_eq!(seen.body["messages"][0]["role"], "user");
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-proxy-pii-redaction-sync")
@@ -230,14 +230,10 @@ fn redaction_test_rules() -> serde_json::Value {
])
}
fn chat_pii_redaction_feature_settings(
enabled: bool,
inject_model_instruction: bool,
) -> serde_json::Value {
fn chat_pii_redaction_feature_settings(enabled: bool) -> serde_json::Value {
json!({
"chat_pii_redaction": {
"enabled": enabled,
"inject_model_instruction": inject_model_instruction,
}
})
}
@@ -245,7 +241,6 @@ fn chat_pii_redaction_feature_settings(
fn auth_repository_with_redaction_feature_settings(
test_id: &str,
feature_enabled: bool,
inject_model_instruction: bool,
) -> Arc<InMemoryAuthApiKeySnapshotRepository> {
let snapshot = auth_snapshot(&format!("api-key-{test_id}"), &format!("user-{test_id}"));
let key_hash = hash_api_key(&format!("sk-client-{test_id}"));
@@ -257,10 +252,7 @@ fn auth_repository_with_redaction_feature_settings(
.with_export_records(vec![auth_export_record(
&snapshot,
key_hash,
Some(chat_pii_redaction_feature_settings(
feature_enabled,
inject_model_instruction,
)),
Some(chat_pii_redaction_feature_settings(feature_enabled)),
)]),
)
}
@@ -372,8 +364,7 @@ async fn run_sync_redaction_case_with_system_config(
}),
);
let (provider_url, provider_handle) = start_server(provider_app).await;
let auth_repository =
auth_repository_with_redaction_feature_settings(test_id, feature_enabled, true);
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, feature_enabled);
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
candidate_row(test_id),
@@ -522,14 +513,9 @@ async fn ai_execute_sync_pii_redaction_round_trip_impl() {
assert!(provider_body_text.contains("<AETHER:ACCESS_TOKEN:"));
assert!(provider_body_text.contains("<AETHER:SECRET_KEY:"));
assert_eq!(seen.body["messages"][0]["role"], "system");
assert_eq!(seen.body["messages"][1]["role"], "assistant");
let notice = seen.body["messages"][1]["content"]
.as_str()
.expect("notice should be text");
assert!(notice.contains("not a user request"));
assert_eq!(seen.body["messages"][2]["role"], "user");
assert_eq!(seen.body["messages"][3]["role"], "assistant");
assert_eq!(seen.body["messages"][4]["role"], "tool");
assert_eq!(seen.body["messages"][1]["role"], "user");
assert_eq!(seen.body["messages"][2]["role"], "assistant");
assert_eq!(seen.body["messages"][3]["role"], "tool");
let response_content = response_json["choices"][0]["message"]["content"]
.as_str()
@@ -702,7 +688,7 @@ async fn ai_execute_pii_redaction_restores_executed_candidate_session_after_late
);
let (provider_url, provider_handle) = start_server(provider_app).await;
let auth_repository =
auth_repository_with_redaction_feature_settings("redaction-candidate-session", true, true);
auth_repository_with_redaction_feature_settings("redaction-candidate-session", true);
let mut later_candidate = candidate_row("redaction-candidate-session");
later_candidate.provider_id = "provider-redaction-candidate-session-later".to_string();
later_candidate.endpoint_id = "endpoint-redaction-candidate-session-later".to_string();
@@ -817,7 +803,7 @@ async fn pii_redaction_performance_limits_do_not_forward_unredacted_body_upstrea
);
let (provider_url, provider_handle) = start_server(provider_app).await;
let auth_repository =
auth_repository_with_redaction_feature_settings("pii-redaction-limit", true, true);
auth_repository_with_redaction_feature_settings("pii-redaction-limit", true);
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
candidate_row("pii-redaction-limit"),
@@ -893,7 +879,7 @@ async fn ai_execute_pii_redaction_missing_encryption_key_fails_closed_before_pro
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let test_id = "ai-execute-pii-redaction-missing-encryption-key";
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, true, true);
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, true);
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
candidate_row(test_id),
@@ -498,8 +498,7 @@ fn auth_repository(case: &RedactionFormatCase) -> Arc<InMemoryAuthApiKeySnapshot
key_hash,
Some(json!({
"chat_pii_redaction": {
"enabled": true,
"inject_model_instruction": true
"enabled": true
}
})),
)]),
@@ -117,6 +117,48 @@ fn admin_provider_oauth_complete_dispatch_remains_thin() {
}
}
#[test]
fn postgres_provider_cleanup_preserves_usage_history() {
let postgres_provider_catalog =
read_workspace_file("crates/aether-data/src/repository/provider_catalog/postgres.rs");
for forbidden in [
"UPDATE usage SET provider_id = NULL",
"UPDATE usage SET provider_endpoint_id = NULL",
"UPDATE usage SET provider_api_key_id = NULL",
] {
assert!(
!postgres_provider_catalog.contains(forbidden),
"provider cleanup must not rewrite usage history with {forbidden}"
);
}
}
#[test]
fn provider_cleanup_keeps_common_backends_in_sync() {
for path in [
"crates/aether-data/src/repository/provider_catalog/postgres.rs",
"crates/aether-data/src/repository/provider_catalog/mysql.rs",
"crates/aether-data/src/repository/provider_catalog/sqlite.rs",
] {
let source = read_workspace_file(path);
for required in [
"UPDATE user_preferences SET default_provider_id = NULL WHERE default_provider_id =",
"UPDATE video_tasks SET provider_id = NULL WHERE provider_id =",
"DELETE FROM request_candidates WHERE provider_id =",
"UPDATE video_tasks SET endpoint_id = NULL WHERE endpoint_id =",
"DELETE FROM request_candidates WHERE endpoint_id =",
"DELETE FROM gemini_file_mappings WHERE key_id =",
"UPDATE video_tasks SET key_id = NULL WHERE key_id =",
] {
assert!(
source.contains(required),
"{path} should keep provider cleanup behavior in sync with {required}"
);
}
}
}
#[test]
fn admin_provider_oauth_complete_helpers_are_split() {
let complete_mod = read_workspace_file(
@@ -2506,7 +2506,7 @@ fn ai_serving_same_format_provider_root_request_separates_body_and_url_policy()
"apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs",
);
for pattern in [
"super::super::request::build_same_format_provider_request_body(",
"super::super::request::build_same_format_provider_request_body_with_compatibility_report(",
"super::super::request::build_same_format_upstream_url(",
] {
assert!(
@@ -5403,7 +5403,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
assert_eq!(
stored_key.oauth_invalid_reason.as_deref(),
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)")
Some("[OAUTH_EXPIRED] Codex Token 已过期 (401)")
);
} else if account_state_recheck_attempted
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 403"
@@ -5449,7 +5449,40 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
oauth_snapshot.get("expires_at"),
auth_config.get("expires_at")
);
if stored_key.oauth_invalid_reason.is_some() {
if stored_key
.oauth_invalid_reason
.as_deref()
.is_some_and(|reason| reason.starts_with("[OAUTH_EXPIRED]"))
{
assert_eq!(
oauth_snapshot
.get("code")
.and_then(serde_json::Value::as_str),
Some("expired")
);
assert_eq!(
oauth_snapshot
.get("label")
.and_then(serde_json::Value::as_str),
Some("已过期")
);
assert_eq!(
oauth_snapshot.get("reason"),
Some(&json!("Codex Token 已过期 (401)"))
);
assert_eq!(
oauth_snapshot
.get("requires_reauth")
.and_then(serde_json::Value::as_bool),
Some(false)
);
assert_eq!(
oauth_snapshot
.get("expiring_soon")
.and_then(serde_json::Value::as_bool),
Some(false)
);
} else if stored_key.oauth_invalid_reason.is_some() {
assert_eq!(
oauth_snapshot
.get("code")
@@ -5463,8 +5496,11 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
Some("已失效")
);
assert_eq!(
oauth_snapshot.get("reason"),
Some(&json!("Codex Token 无效或已过期 (401)"))
oauth_snapshot
.get("reason")
.and_then(serde_json::Value::as_str)
.is_some_and(|reason| !reason.trim().is_empty()),
true
);
assert_eq!(
oauth_snapshot
@@ -3526,6 +3526,15 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
);
banned_key.name = "banned".to_string();
banned_key.oauth_invalid_reason = Some("account_banned".to_string());
let mut oauth_invalidated_key = sample_key(
"key-openai-oauth-invalidated",
"provider-openai",
"openai:chat",
"sk-oauth-invalidated",
);
oauth_invalidated_key.name = "oauth-invalidated".to_string();
oauth_invalidated_key.oauth_invalid_reason =
Some("[OAUTH_EXPIRED] token invalidated".to_string());
let mut oauth_expired_key = sample_key(
"key-openai-oauth-expired",
"provider-openai",
@@ -3533,7 +3542,7 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
"sk-oauth-expired",
);
oauth_expired_key.name = "oauth-expired".to_string();
oauth_expired_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
oauth_expired_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
let mut healthy_key = sample_key(
"key-openai-healthy",
"provider-openai",
@@ -3545,7 +3554,12 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
vec![banned_key, oauth_expired_key, healthy_key],
vec![
banned_key,
oauth_invalidated_key,
oauth_expired_key,
healthy_key,
],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -3575,8 +3589,8 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["affected"], 1);
assert_eq!(payload["message"], "已清理 1 个异常账号");
assert_eq!(payload["affected"], 2);
assert_eq!(payload["message"], "已清理 2 个异常账号");
let remaining_keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()])
@@ -3586,6 +3600,9 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
assert!(remaining_keys
.iter()
.any(|key| key.id == "key-openai-oauth-expired"));
assert!(!remaining_keys
.iter()
.any(|key| key.id == "key-openai-oauth-invalidated"));
assert!(remaining_keys
.iter()
.any(|key| key.id == "key-openai-healthy"));
@@ -1703,6 +1703,52 @@ async fn gateway_submits_admin_provider_delete_task_locally_with_trusted_admin_p
upstream_handle.abort();
}
#[test]
fn provider_delete_task_reservation_reuses_active_provider_task() {
let state = AppState::new().expect("gateway should build");
let first = crate::LocalProviderDeleteTaskState {
task_id: "task-first".to_string(),
provider_id: "provider-openai".to_string(),
status: "pending".to_string(),
stage: "queued".to_string(),
total_keys: 0,
deleted_keys: 0,
total_endpoints: 0,
deleted_endpoints: 0,
message: "delete task submitted".to_string(),
};
let second = crate::LocalProviderDeleteTaskState {
task_id: "task-second".to_string(),
provider_id: "provider-openai".to_string(),
status: "pending".to_string(),
stage: "queued".to_string(),
total_keys: 0,
deleted_keys: 0,
total_endpoints: 0,
deleted_endpoints: 0,
message: "delete task submitted".to_string(),
};
assert_eq!(
state.reserve_provider_delete_task(first.clone()).task_id,
"task-first"
);
assert_eq!(
state.reserve_provider_delete_task(second.clone()).task_id,
"task-first"
);
state.put_provider_delete_task(crate::LocalProviderDeleteTaskState {
status: "completed".to_string(),
stage: "completed".to_string(),
..first
});
assert_eq!(
state.reserve_provider_delete_task(second).task_id,
"task-second"
);
}
#[tokio::test]
async fn local_admin_provider_delete_task_status_attaches_audit_only_for_terminal_states() {
let mut completed_state = AppState::new().expect("gateway should build");
@@ -1172,7 +1172,7 @@ async fn gateway_handles_admin_usage_active_ids_for_terminal_updates() {
#[tokio::test]
async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principal() {
let (upstream_url, upstream_hits, upstream_handle) =
let (_upstream_url, upstream_hits, upstream_handle) =
start_usage_upstream("/api/admin/usage/records").await;
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
@@ -1336,6 +1336,100 @@ async fn gateway_filters_admin_usage_records_with_unknown_model_or_provider() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_supports_fast_admin_usage_record_totals() {
let (upstream_url, upstream_hits, upstream_handle) =
start_usage_upstream("/api/admin/usage/records").await;
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_usage_row(
"usage-a",
"req-a",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
120,
30,
0.3,
0.36,
DAY_2_UNIX_SECS,
),
sample_usage_row(
"usage-b",
"req-b",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5-mini",
"completed",
80,
20,
0.2,
0.24,
DAY_2_UNIX_SECS - 1,
),
sample_usage_row(
"usage-c",
"req-c",
Some("user-1"),
Some("key-1"),
Some("primary"),
"Anthropic",
"claude-sonnet",
"completed",
60,
10,
0.1,
0.12,
DAY_1_UNIX_SECS,
),
]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(
usage_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let fast_response = admin_request(reqwest::Client::new().get(format!(
"{gateway_url}/api/admin/usage/records?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0&include_total=false&limit=2&offset=0"
)))
.send()
.await
.expect("request should succeed");
assert_eq!(fast_response.status(), StatusCode::OK);
let fast_payload: serde_json::Value =
fast_response.json().await.expect("json body should parse");
assert_eq!(fast_payload["records"].as_array().unwrap().len(), 2);
assert_eq!(fast_payload["total"], 3);
assert_eq!(fast_payload["total_is_estimated"], true);
let total_response = admin_request(reqwest::Client::new().get(format!(
"{gateway_url}/api/admin/usage/records?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0&total_only=true&limit=2&offset=0"
)))
.send()
.await
.expect("request should succeed");
assert_eq!(total_response.status(), StatusCode::OK);
let total_payload: serde_json::Value =
total_response.json().await.expect("json body should parse");
assert_eq!(total_payload["records"].as_array().unwrap().len(), 0);
assert_eq!(total_payload["total"], 3);
assert_eq!(total_payload["total_is_estimated"], false);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_usage_records_with_provider_key_name_fallback_from_request_metadata()
{
@@ -4999,8 +4999,7 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
"username": "alice-updated",
"feature_settings": {
"chat_pii_redaction": {
"enabled": true,
"inject_model_instruction": false
"enabled": true
}
}
}))
@@ -5034,10 +5033,6 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
get_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
true
);
assert_eq!(
get_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
false
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -7167,8 +7162,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
"concurrent_limit": 4,
"feature_settings": {
"chat_pii_redaction": {
"enabled": true,
"inject_model_instruction": false
"enabled": true
}
}
}))
@@ -7187,10 +7181,6 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
update_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
true
);
assert_eq!(
update_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
false
);
assert_eq!(update_payload["message"], "API密钥已更新");
let toggle_response = client
+26 -2
View File
@@ -56,6 +56,16 @@ fn unix_secs_to_rfc3339(unix_secs: u64) -> Option<String> {
Some(timestamp.to_rfc3339())
}
fn admin_usage_response_time_updated_at(item: &StoredRequestUsageAudit) -> Option<String> {
item.response_time_ms?;
if matches!(item.status.as_str(), "pending" | "streaming")
&& item.updated_at_unix_secs <= item.created_at_unix_ms
{
return None;
}
unix_secs_to_rfc3339(item.updated_at_unix_secs)
}
pub fn admin_usage_parse_limit(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "limit") {
None => Ok(100),
@@ -1196,6 +1206,8 @@ fn admin_usage_active_request_json(
"actual_cost": round_to(item.actual_total_cost_usd, 6),
"response_time_ms": item.response_time_ms,
"first_byte_time_ms": item.first_byte_time_ms,
"updated_at": unix_secs_to_rfc3339(item.updated_at_unix_secs),
"response_time_updated_at": admin_usage_response_time_updated_at(item),
"status_code": item.status_code,
"error_message": item.error_message,
"provider": item.provider_name,
@@ -2259,6 +2271,7 @@ pub fn build_admin_usage_active_requests_response(
auth_api_key_reader_available: bool,
provider_key_names: &BTreeMap<String, String>,
image_progress_by_request_id: &BTreeMap<String, Value>,
state_overrides_by_request_id: &BTreeMap<String, Value>,
) -> Response<Body> {
let payload: Vec<_> = items
.iter()
@@ -2266,12 +2279,23 @@ pub fn build_admin_usage_active_requests_response(
let provider_key_name = admin_usage_provider_key_name(item, provider_key_names);
let api_key_name =
admin_usage_api_key_name(item, api_key_names, auth_api_key_reader_available);
admin_usage_active_request_json(
let mut payload = admin_usage_active_request_json(
item,
api_key_name,
provider_key_name,
image_progress_by_request_id.get(&item.request_id),
)
);
if let (Some(payload), Some(overrides)) = (
payload.as_object_mut(),
state_overrides_by_request_id
.get(&item.request_id)
.and_then(Value::as_object),
) {
for (key, value) in overrides {
payload.insert(key.clone(), value.clone());
}
}
payload
})
.collect();
+43 -8
View File
@@ -948,10 +948,29 @@ pub fn codex_build_invalid_state(
pub fn codex_looks_like_token_invalidated(message: Option<&str>) -> bool {
let lowered = message.unwrap_or_default().trim().to_ascii_lowercase();
lowered.contains("token invalid")
lowered.contains("token_invalidated")
|| lowered.contains("authentication token has been invalidated")
|| lowered.contains("token has been invalidated")
|| lowered.contains("token invalidated")
|| lowered.contains("session has expired")
|| lowered.contains("invalidated")
|| lowered.contains("revoked")
|| lowered.contains("已撤销")
|| lowered.contains("被撤销")
|| lowered.contains("撤销")
|| lowered.contains("作废")
}
pub fn codex_looks_like_token_expired(message: Option<&str>) -> bool {
let lowered = message.unwrap_or_default().trim().to_ascii_lowercase();
lowered.contains("session has expired")
|| lowered.contains("session expired")
|| lowered.contains("access token expired")
|| lowered.contains("expired access token")
|| lowered.contains("token has expired")
|| lowered.contains("token expired")
|| lowered.contains("security token included in the request is expired")
|| lowered.contains("已过期")
|| lowered.contains("过期")
}
fn codex_looks_like_account_deactivated(message: Option<&str>) -> bool {
@@ -980,7 +999,15 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
}
if codex_looks_like_token_invalidated(Some(message)) {
let detail = if message.is_empty() {
"Codex Token 无效或已过期"
"Codex Token 已失效"
} else {
message
};
return format!("{OAUTH_EXPIRED_PREFIX}{detail}");
}
if codex_looks_like_token_expired(Some(message)) {
let detail = if message.is_empty() {
"Codex Token 已过期"
} else {
message
};
@@ -988,7 +1015,7 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
}
if status_code == 401 {
let detail = if message.is_empty() {
"Codex Token 无效或已过期 (401)"
"Codex Token 已过期 (401)"
} else {
message
};
@@ -1021,6 +1048,7 @@ pub fn codex_runtime_invalid_reason(
401 => Some(codex_structured_invalid_reason(401, upstream_message)),
402 => Some(codex_structured_invalid_reason(402, upstream_message)),
403 if codex_looks_like_token_invalidated(upstream_message)
|| codex_looks_like_token_expired(upstream_message)
|| codex_looks_like_account_deactivated(upstream_message) =>
{
Some(codex_structured_invalid_reason(403, upstream_message))
@@ -1848,12 +1876,19 @@ mod tests {
}
#[test]
fn auto_remove_structured_reason_keeps_oauth_expired_token_invalid() {
assert!(!should_auto_remove_structured_reason(Some(
fn auto_remove_structured_reason_removes_oauth_token_invalidated() {
assert!(should_auto_remove_structured_reason(Some(
"[OAUTH_EXPIRED] token invalidated"
)));
}
#[test]
fn auto_remove_structured_reason_keeps_oauth_token_expired() {
assert!(!should_auto_remove_structured_reason(Some(
"[OAUTH_EXPIRED] session expired"
)));
}
#[test]
fn auto_remove_refresh_failed_after_access_token_expiry() {
let mut key = StoredProviderCatalogKey::new(
@@ -1945,7 +1980,7 @@ mod tests {
}
#[test]
fn oauth_token_invalid_is_not_auto_remove_proof_by_itself() {
fn oauth_token_invalid_is_auto_remove_proof_by_itself() {
let mut key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
@@ -1958,7 +1993,7 @@ mod tests {
key.expires_at_unix_secs = Some(1_000);
key.oauth_invalid_reason = Some("oauth_token_invalid".to_string());
assert!(!super::should_auto_remove_oauth_invalid_key(
assert!(super::should_auto_remove_oauth_invalid_key(
&key,
Some("oauth_token_invalid"),
false,
+116 -11
View File
@@ -26,6 +26,10 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS: &[&str] = &[
"账户访问被禁止",
"访问受限",
"账户访问受限",
"oauth_token_invalid",
"oauth_token_expired",
"token_invalidated",
"session expired",
"authentication token has been invalidated",
"token has been invalidated",
"codex token 无效或已过期",
@@ -43,6 +47,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
"account_quarantined",
"workspace_deactivated",
"account_forbidden",
"oauth_token_invalid",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -122,8 +127,79 @@ fn looks_like_account_verification(reason: &str) -> bool {
.any(|keyword| lowered.contains(keyword))
}
pub fn oauth_token_reason_is_expired(reason: &str) -> bool {
let lowered = reason.trim().to_ascii_lowercase();
!lowered.is_empty()
&& [
"oauth_token_expired",
"session has expired",
"session expired",
"access token expired",
"expired access token",
"token expired",
"token has expired",
"security token included in the request is expired",
"已过期",
"过期",
]
.iter()
.any(|keyword| lowered.contains(keyword))
}
pub fn oauth_token_reason_is_hard_invalid(reason: &str) -> bool {
let lowered = reason.trim().to_ascii_lowercase();
if lowered.is_empty() {
return false;
}
if [
"oauth_token_invalid",
"token_invalidated",
"authentication token has been invalidated",
"token has been invalidated",
"token invalidated",
"invalidated",
"revoked",
"已撤销",
"被撤销",
"撤销",
"已作废",
"作废",
"已失效",
"token 失效",
"令牌失效",
]
.iter()
.any(|keyword| lowered.contains(keyword))
{
return true;
}
(lowered.contains("token 无效") || lowered.contains("令牌无效"))
&& !oauth_token_reason_is_expired(reason)
}
pub fn oauth_token_account_status_parts(reason: &str) -> (&'static str, &'static str) {
if oauth_token_reason_is_hard_invalid(reason) {
("oauth_token_invalid", "Token 失效")
} else {
("oauth_token_expired", "Token 过期")
}
}
pub fn oauth_token_snapshot_status_parts(reason: &str) -> (&'static str, &'static str) {
if oauth_token_reason_is_hard_invalid(reason) {
("invalid", "已失效")
} else {
("expired", "已过期")
}
}
fn classify_block_reason(reason: &str) -> (&'static str, &'static str) {
let lowered = reason.to_ascii_lowercase();
if oauth_token_reason_is_hard_invalid(reason) || oauth_token_reason_is_expired(reason) {
return oauth_token_account_status_parts(reason);
}
if [
"authentication token has been invalidated",
"token has been invalidated",
@@ -352,17 +428,18 @@ fn resolve_from_oauth_invalid_reason(reason: Option<&str>) -> Option<PoolAccount
}
if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") {
let reason = if cleaned.is_empty() {
"OAuth Token 已过期且无法续期".to_string()
"OAuth Token 已过期".to_string()
} else {
cleaned
};
let (code, label) = oauth_token_account_status_parts(&reason);
return Some(PoolAccountState {
blocked: true,
code: Some("oauth_token_invalid".to_string()),
label: Some("Token 失效".to_string()),
code: Some(code.to_string()),
label: Some(label.to_string()),
reason: Some(reason),
source: Some("oauth_invalid".to_string()),
recoverable: false,
recoverable: code == "oauth_token_expired",
});
}
if let Some(cleaned) = tagged_reason(&text, "REQUEST_FAILED") {
@@ -456,17 +533,18 @@ pub fn resolve_account_status_snapshot(
if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") {
let reason = if cleaned.is_empty() {
"OAuth Token 已过期且无法续期".to_string()
"OAuth Token 已过期".to_string()
} else {
cleaned
};
let (code, label) = oauth_token_account_status_parts(&reason);
return AccountStatusSnapshot {
code: "oauth_token_invalid".to_string(),
label: Some("Token 失效".to_string()),
code: code.to_string(),
label: Some(label.to_string()),
reason: Some(reason),
blocked: true,
source: Some("oauth_invalid".to_string()),
recoverable: false,
recoverable: code == "oauth_token_expired",
};
}
@@ -631,11 +709,11 @@ mod tests {
}
#[test]
fn account_snapshot_marks_oauth_expired_as_token_invalid() {
fn account_snapshot_marks_oauth_invalidated_as_token_invalid() {
let snapshot = resolve_account_status_snapshot(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)"),
Some("[OAUTH_EXPIRED] Your authentication token has been invalidated. Please try signing in again."),
);
assert_eq!(snapshot.code, "oauth_token_invalid");
@@ -645,7 +723,21 @@ mod tests {
}
#[test]
fn oauth_expired_state_is_not_auto_removed() {
fn account_snapshot_marks_oauth_expired_as_token_expired() {
let snapshot = resolve_account_status_snapshot(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] session expired"),
);
assert_eq!(snapshot.code, "oauth_token_expired");
assert_eq!(snapshot.label.as_deref(), Some("Token 过期"));
assert!(snapshot.blocked);
assert!(snapshot.recoverable);
}
#[test]
fn oauth_invalidated_state_is_auto_removed() {
let state = resolve_pool_account_state(
Some("codex"),
None,
@@ -654,6 +746,19 @@ mod tests {
assert!(state.blocked);
assert_eq!(state.code.as_deref(), Some("oauth_token_invalid"));
assert!(should_auto_remove_account_state(&state));
}
#[test]
fn oauth_expired_state_is_not_auto_removed() {
let state = resolve_pool_account_state(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] session expired"),
);
assert!(state.blocked);
assert_eq!(state.code.as_deref(), Some("oauth_token_expired"));
assert!(!should_auto_remove_account_state(&state));
}
+9 -6
View File
@@ -251,16 +251,19 @@ pub use aether_ai_formats::{
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
canonical_to_openai_responses_response, canonical_unknown_block_count, convert_request,
convert_response, from_claude_to_canonical_request, from_claude_to_canonical_response,
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
convert_request_pure, convert_request_pure_with_context, convert_response,
convert_response_pure, emit_request_pure, emit_response_pure, from_claude_to_canonical_request,
from_claude_to_canonical_response, from_gemini_to_canonical_request,
from_gemini_to_canonical_response, from_openai_chat_to_canonical_request,
from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request,
from_openai_responses_to_canonical_response, parse_request_pure, parse_response_pure,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, CanonicalContentBlock,
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
CanonicalUsage, FormatContext, FormatError, FormatFamily, FormatId, FormatProfile,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
CanonicalUsage, ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted,
FormatContext, FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
@@ -1,11 +1,58 @@
use serde_json::{Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let contents = request
.get("input")
.and_then(Value::as_object)
.and_then(|input| input.get("contents"))
.and_then(Value::as_array)?;
let input = contents_to_embedding_input(contents)?;
let mut parameters = request
.get("parameters")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let dimensions = parameters
.remove("dimension")
.or_else(|| request.get("dimensions").cloned())
.and_then(|value| value.as_u64());
let parameters = (!parameters.is_empty()).then_some(parameters);
Some(CanonicalRequest {
model,
embedding: Some(crate::protocol::canonical::CanonicalEmbeddingRequest {
input,
encoding_format: None,
dimensions,
task: None,
user: None,
parameters,
extensions: namespace_extensions(
"aliyun",
request,
&["model", "input", "parameters", "dimensions"],
),
}),
..CanonicalRequest::default()
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let contents = embedding_input_to_contents(&embedding.input)?;
@@ -42,6 +89,67 @@ pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
Some(Value::Object(output))
}
fn contents_to_embedding_input(contents: &[Value]) -> Option<CanonicalEmbeddingInput> {
if contents.is_empty() {
return None;
}
let parsed = contents
.iter()
.map(embedding_content_from_value)
.collect::<Option<Vec<_>>>()?;
if parsed.iter().all(|content| {
content.image.is_none() && content.video.is_none() && content.multi_images.is_none()
}) {
return Some(CanonicalEmbeddingInput::StringArray(
parsed
.into_iter()
.map(|content| content.text)
.collect::<Option<Vec<_>>>()?,
));
}
Some(CanonicalEmbeddingInput::Multimodal(parsed))
}
fn embedding_content_from_value(value: &Value) -> Option<CanonicalEmbeddingContent> {
let object = value.as_object()?;
let content = CanonicalEmbeddingContent {
text: object
.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
image: object
.get("image")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
video: object
.get("video")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
multi_images: match object.get("multi_images").and_then(Value::as_array) {
Some(values) => Some(
values
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?,
),
None => None,
},
};
(!content.is_empty()).then_some(content)
}
fn embedding_input_to_contents(input: &CanonicalEmbeddingInput) -> Option<Vec<Value>> {
match input {
CanonicalEmbeddingInput::String(text) => {
@@ -5,7 +5,8 @@ use serde_json::{json, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
canonical_blocks_to_claude, canonical_extension_object_mut,
canonical_stop_reason_to_claude, canonical_usage_to_claude,
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole,
@@ -28,6 +29,27 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let content = claude_content_to_canonical_blocks(body.get("content"))?;
let stop_reason =
claude_stop_reason_to_canonical(body.get("stop_reason").and_then(Value::as_str));
let mut extensions = claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
);
if let Some(raw_stop_reason) = body.get("stop_reason").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_reason".to_string(), raw_stop_reason);
}
if let Some(raw_stop_sequence) = body.get("stop_sequence").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_sequence".to_string(), raw_stop_sequence);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -49,19 +71,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
content,
stop_reason,
usage: claude_usage_to_canonical(body.get("usage")),
extensions: claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
),
extensions,
})
}
@@ -86,12 +96,23 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
"output_tokens": 0,
})),
});
if let Some(claude) = canonical
.extensions
.get("claude")
.and_then(Value::as_object)
{
if let Some(raw_stop_reason) = claude.get("raw_stop_reason").cloned() {
response["stop_reason"] = raw_stop_reason;
}
if let Some(raw_stop_sequence) = claude.get("raw_stop_sequence").cloned() {
response["stop_sequence"] = raw_stop_sequence;
}
}
if let Some(object) = response.as_object_mut() {
object.extend(namespace_extension_object(
&canonical.extensions,
"claude",
object,
));
let mut extra = namespace_extension_object(&canonical.extensions, "claude", object);
extra.remove("raw_stop_reason");
extra.remove("raw_stop_sequence");
object.extend(extra);
}
response
}
@@ -322,8 +322,7 @@ impl ClaudeProviderState {
let finish_reason = map_claude_stop_reason(
delta.get("stop_reason").and_then(Value::as_str),
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
)
.map(ToOwned::to_owned);
);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
+160 -4
View File
@@ -1,5 +1,6 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Default)]
@@ -31,6 +32,15 @@ impl FormatContext {
self
}
pub fn without_runtime_request_edits(&self) -> Self {
Self {
mapped_model: None,
request_path: self.request_path.clone(),
upstream_is_stream: false,
report_context: self.report_context.clone(),
}
}
pub(crate) fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
self.mapped_model
.as_deref()
@@ -47,13 +57,116 @@ impl FormatContext {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConversionFieldStatus {
Native,
Mapped,
ExtensionPreserved,
Unaudited,
Unsupported,
InvalidEnum,
LossyBlocked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionFieldRecord {
pub field: String,
pub status: ConversionFieldStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ConversionFieldRecord {
pub fn new(
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) -> Self {
Self {
field: field.into(),
status,
detail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionReport {
pub source_format: String,
pub target_format: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<ConversionFieldRecord>,
}
impl ConversionReport {
pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
Self {
source_format: source_format.into(),
target_format: target_format.into(),
fields: Vec::new(),
}
}
pub fn record(
&mut self,
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) {
self.fields
.push(ConversionFieldRecord::new(field, status, detail));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Converted<T> {
pub value: T,
pub report: ConversionReport,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
UnsupportedFormat(String),
RequestParseFailed { format: String },
RequestEmitFailed { format: String },
ResponseParseFailed { format: String },
ResponseEmitFailed { format: String },
RequestParseFailed {
format: String,
},
RequestEmitFailed {
format: String,
},
ResponseParseFailed {
format: String,
},
ResponseEmitFailed {
format: String,
},
UnsupportedField {
format: String,
field: String,
reason: String,
},
UnauditedField {
source_format: String,
target_format: String,
field: String,
reason: String,
},
InvalidEnumValue {
format: String,
field: String,
value: String,
},
LossyConversionBlocked {
source_format: String,
target_format: String,
field: String,
reason: String,
},
InvalidTargetField {
format: String,
field: String,
reason: String,
},
}
impl fmt::Display for FormatError {
@@ -70,6 +183,49 @@ impl fmt::Display for FormatError {
Self::ResponseEmitFailed { format } => {
write!(f, "failed to emit {format} response")
}
Self::UnsupportedField {
format,
field,
reason,
} => {
write!(f, "unsupported field {field} in {format}: {reason}")
}
Self::UnauditedField {
source_format,
target_format,
field,
reason,
} => {
write!(
f,
"unaudited field {field} in {source_format} cannot be converted to {target_format}: {reason}"
)
}
Self::InvalidEnumValue {
format,
field,
value,
} => {
write!(f, "invalid enum value {value:?} for {format}.{field}")
}
Self::LossyConversionBlocked {
source_format,
target_format,
field,
reason,
} => {
write!(
f,
"lossy conversion blocked from {source_format} to {target_format} at {field}: {reason}"
)
}
Self::InvalidTargetField {
format,
field,
reason,
} => {
write!(f, "invalid target field {field} for {format}: {reason}")
}
}
}
}
@@ -723,15 +723,13 @@ mod tests {
"file": {"file_data": "data:application/pdf;base64,JVBERi0x"}
}),
json!({"type": "text", "text": "[File: https://example.com/report.pdf]"}),
json!({
"type": "text",
"text": "[Claude tool_result document content omitted: text/plain]"
}),
json!({"type": "text", "text": "document body"}),
]
);
let block_content_json = Value::Array(block_content.clone()).to_string();
assert!(!block_content_json.contains("\"source\""));
assert!(!block_content_json.contains("document body"));
assert!(block_content_json.contains("document body"));
assert!(!block_content_json.contains("content omitted"));
}
#[test]
@@ -814,6 +812,34 @@ mod tests {
}
}
#[test]
fn openai_responses_request_normalizer_strips_content_cache_control() {
let body = json!({
"model": "gpt-5.1",
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "stable project brief",
"cache_control": {"type": "ephemeral"}
}]
}],
"prompt_cache_key": "cache_123"
});
let converted = registry::convert_request(
"openai:responses",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect("responses request");
assert_eq!(converted["prompt_cache_key"], "cache_123");
assert!(!converted["input"].to_string().contains("cache_control"));
}
#[test]
fn claude_output_config_effort_controls_responses_reasoning() {
let body = json!({
@@ -932,4 +958,90 @@ mod tests {
"data:image/png;base64,AAAA"
);
}
#[test]
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read",
"content": [{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:responses"
&& field == "messages[].content[].tool_result.content"
));
}
#[test]
fn claude_request_to_openai_chat_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read",
"content": [{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed for Chat");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:chat"
&& field == "messages[].content[].tool_result.content"
));
}
}
@@ -6,7 +6,10 @@
use serde_json::{json, Value};
use crate::formats::{context::FormatContext, registry};
use crate::formats::{
context::FormatContext,
openai::responses::response::ensure_modern_openai_responses_response_fields, registry,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenAiResponsesResponseUsage {
@@ -218,7 +221,7 @@ pub fn build_openai_responses_response_with_content(
}));
}
output.extend(function_calls);
json!({
let mut response = json!({
"id": response_id,
"object": "response",
"status": "completed",
@@ -229,7 +232,11 @@ pub fn build_openai_responses_response_with_content(
"output_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens,
}
})
});
if let Some(response_object) = response.as_object_mut() {
ensure_modern_openai_responses_response_fields(response_object);
}
response
}
fn response_context(report_context: &Value) -> FormatContext {
@@ -273,6 +280,26 @@ mod tests {
assert_eq!(converted["object"], "response");
assert_eq!(converted["output"][0]["type"], "message");
assert_eq!(converted["output_text"], "hello");
assert!(converted["created_at"].as_i64().is_some());
assert!(converted["completed_at"].as_i64().is_some());
}
#[test]
fn manual_responses_response_builder_emits_modern_fields() {
let response = super::build_openai_responses_response(
"resp_manual_123",
"gpt-5",
"Hello manual",
Vec::new(),
1,
2,
3,
);
assert_eq!(response["output_text"], "Hello manual");
assert!(response["created_at"].as_i64().is_some());
assert!(response["completed_at"].as_i64().is_some());
}
#[test]
@@ -5,6 +5,10 @@ use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{namespace_extension_object, CanonicalRequest};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
crate::formats::openai::embedding::request::from_namespace(body, "doubao")
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let items = embedding.input.as_string_items()?;
@@ -1,8 +1,137 @@
use serde_json::{json, Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{CanonicalEmbeddingRequest, CanonicalRequest};
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
if let Some(requests) = request.get("requests").and_then(Value::as_array) {
return from_batch_requests(request, requests);
}
let item = parse_gemini_embedding_request_object(request)?;
Some(CanonicalRequest {
model: item.model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::String(item.text),
encoding_format: None,
dimensions: item.dimensions,
task: item.task,
user: None,
parameters: None,
extensions: namespace_extensions(
"gemini",
request,
&[
"model",
"content",
"outputDimensionality",
"output_dimensionality",
"taskType",
"task_type",
],
),
}),
..CanonicalRequest::default()
})
}
fn from_batch_requests(
request: &Map<String, Value>,
requests: &[Value],
) -> Option<CanonicalRequest> {
if requests.is_empty() {
return None;
}
let items = requests
.iter()
.map(|request| parse_gemini_embedding_request_object(request.as_object()?))
.collect::<Option<Vec<_>>>()?;
let first = items.first()?;
if items.iter().any(|item| {
item.model != first.model || item.dimensions != first.dimensions || item.task != first.task
}) {
return None;
}
let model = first.model.clone();
let dimensions = first.dimensions;
let task = first.task.clone();
Some(CanonicalRequest {
model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::StringArray(
items.into_iter().map(|item| item.text).collect(),
),
encoding_format: None,
dimensions,
task,
user: None,
parameters: None,
extensions: namespace_extensions("gemini", request, &["requests"]),
}),
..CanonicalRequest::default()
})
}
struct ParsedGeminiEmbeddingRequest {
model: String,
text: String,
dimensions: Option<u64>,
task: Option<String>,
}
fn parse_gemini_embedding_request_object(
request: &Map<String, Value>,
) -> Option<ParsedGeminiEmbeddingRequest> {
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let parts = request
.get("content")
.and_then(Value::as_object)
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)?;
let text = parts
.iter()
.map(|part| {
part.as_object()?
.get("text")?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?
.join("\n");
if text.trim().is_empty() {
return None;
}
Some(ParsedGeminiEmbeddingRequest {
model,
text,
dimensions: request
.get("outputDimensionality")
.or_else(|| request.get("output_dimensionality"))
.and_then(Value::as_u64),
task: request
.get("taskType")
.or_else(|| request.get("task_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
@@ -15,7 +15,7 @@ use crate::{
apply_gemini_request_extensions, canonical_extension_object_mut,
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
gemini_generation_config_extra, gemini_google_search_grounding, gemini_openai_extra_body,
gemini_generation_config_extra, gemini_google_search_grounding,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
@@ -173,10 +173,6 @@ pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalReques
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_tool_config".to_string(), tool_config);
}
if let Some(extra_body) = gemini_openai_extra_body(request) {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("extra_body".to_string(), extra_body);
}
if let Some(web_search_options) = web_search_options {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("web_search_options".to_string(), web_search_options);
@@ -352,6 +348,7 @@ fn canonical_block_to_gemini_part(
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone()
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
.unwrap_or_else(|| tool_use_id.clone()),
@@ -676,12 +673,21 @@ fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value
Value::String(description.clone()),
);
}
let raw_parameters = tool
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("raw_parameters"))
.cloned();
declaration.insert(
"parameters".to_string(),
tool.parameters
raw_parameters
.clone()
.or_else(|| tool.parameters.clone())
.map(|mut schema| {
clean_gemini_schema(&mut schema);
if raw_parameters.is_none() {
clean_gemini_schema(&mut schema);
}
schema
})
.unwrap_or_else(|| json!({})),
@@ -808,7 +814,7 @@ mod tests {
use crate::CanonicalContentBlock;
#[test]
fn canonical_tool_result_to_gemini_request_omits_function_response_id() {
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
let mut tool_name_by_id = BTreeMap::new();
tool_name_by_id.insert("call_1".to_string(), "lookup".to_string());
@@ -831,7 +837,7 @@ mod tests {
.and_then(Value::as_object)
.expect("functionResponse should exist");
assert!(!function_response.contains_key("id"));
assert_eq!(function_response["id"], "call_1");
assert_eq!(function_response["name"], "lookup");
assert_eq!(
function_response["response"],
@@ -55,6 +55,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
{
stop_reason = Some(CanonicalStopReason::ToolUse);
}
let mut extensions = gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
);
if let Some(raw_finish_reason) = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
.cloned()
{
canonical_extension_object_mut(&mut extensions, "gemini")
.insert("raw_finish_reason".to_string(), raw_finish_reason);
}
outputs.push(CanonicalResponseOutput {
index: candidate_object
.get("index")
@@ -64,10 +76,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
),
extensions,
});
}
outputs.retain(gemini_response_output_has_visible_content);
@@ -177,7 +186,13 @@ fn canonical_to_gemini_response(
});
if let Some(candidate_object) = candidate.as_object_mut() {
if let Some(gemini) = output.extensions.get("gemini").and_then(Value::as_object) {
if let Some(raw_finish_reason) = gemini.get("raw_finish_reason").cloned() {
candidate_object.insert("finishReason".to_string(), raw_finish_reason);
}
for (key, value) in gemini {
if key == "raw_finish_reason" {
continue;
}
candidate_object.entry(key.clone()).or_insert(value.clone());
}
}
@@ -298,13 +298,8 @@ impl GeminiProviderState {
candidate_object.get("finishReason").and_then(Value::as_str)
{
let has_tool_calls = !self.tool_calls.is_empty();
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII"
| "OTHER" => Some("content_filter"),
other => Some(other),
});
let mut finish_reason =
normalize_openai_finish_reason(map_gemini_stream_finish_reason(finish_reason));
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls".to_string());
}
@@ -343,6 +338,23 @@ impl GeminiProviderState {
}
}
fn map_gemini_stream_finish_reason(value: &str) -> Option<&str> {
match value {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => Some("content_filter"),
other => Some(other),
}
}
#[derive(Default)]
struct GeminiClientToolState {
call_id: String,
@@ -5,10 +5,11 @@ use crate::{
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
openai_extensions, openai_generation_config, openai_message_content_blocks,
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config,
canonical_tool_to_openai, is_claude_tool_result, namespace_extension_object,
openai_content_text, openai_extensions, openai_generation_config,
openai_message_content_blocks, openai_response_format_to_canonical,
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
openai_tools_to_canonical, write_openai_generation_config, CanonicalContentBlock,
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
@@ -19,6 +20,9 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
return None;
}
let mut body = to_raw(request);
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
@@ -103,7 +107,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_p",
"top_k",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
@@ -220,6 +223,94 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
request: &CanonicalRequest,
) -> bool {
request.messages.iter().any(|message| {
message.content.iter().any(|block| {
let CanonicalContentBlock::ToolResult {
output, extensions, ..
} = block
else {
return false;
};
is_claude_tool_result(extensions)
&& output
.as_ref()
.and_then(Value::as_array)
.is_some_and(|parts| {
!claude_tool_result_parts_are_openai_chat_representable(parts)
})
})
})
}
pub(crate) fn claude_tool_result_parts_are_openai_chat_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_chat_representable)
}
fn claude_tool_result_part_is_openai_chat_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_chat_representable(part_object),
"document" | "file" => claude_document_block_is_openai_chat_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
"text" => non_empty_source_str(source, "data").is_some(),
_ => false,
}
}
fn non_empty_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
source
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
}
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
@@ -70,6 +70,13 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
let mut extensions = BTreeMap::new();
if let Some(raw_finish_reason) = choice.get("finish_reason").cloned() {
extensions.insert(
"openai".to_string(),
json!({ "raw_finish_reason": raw_finish_reason }),
);
}
outputs.push(CanonicalResponseOutput {
index: choice
.get("index")
@@ -79,7 +86,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: BTreeMap::new(),
extensions,
});
}
let first_output = outputs.first()?;
@@ -123,10 +130,21 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
.iter()
.enumerate()
.map(|(fallback_index, output)| {
let finish_reason = output
.extensions
.get("openai")
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_finish_reason"))
.cloned()
.unwrap_or_else(|| {
Value::String(
canonical_stop_reason_to_openai(output.stop_reason.as_ref()).to_string(),
)
});
json!({
"index": output.index,
"message": canonical_blocks_to_openai_chat_message(&output.content),
"finish_reason": canonical_stop_reason_to_openai(output.stop_reason.as_ref()),
"finish_reason": finish_reason,
})
.as_object()
.map(|choice| {
@@ -2,6 +2,9 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use crate::formats::openai::responses::response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp,
};
use crate::formats::shared::response::build_generated_tool_call_id;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
use crate::formats::shared::stream_core::common::*;
@@ -680,6 +683,136 @@ impl OpenAIResponsesProviderState {
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_custom_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("custom_tool_call") {
return;
}
let name = item
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("custom_tool")
.to_string();
let arguments = tool_arguments_from_maybe_json_string(
item.get("input").or_else(|| item.get("arguments")),
"input",
);
self.emit_generic_tool_call_item(report_context, out, item, output_index, name, arguments);
}
fn emit_shell_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
let name = match item_type {
"local_shell_call" => "local_shell",
"shell_call" => "shell",
_ => return,
};
let arguments = tool_arguments_from_named_fields(
item,
&[
"action",
"environment",
"status",
"created_by",
"max_output_length",
],
);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
name.to_string(),
arguments,
);
}
fn emit_apply_patch_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("apply_patch_call") {
return;
}
let arguments = tool_arguments_from_named_fields(item, &["operation", "status"]);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
"apply_patch".to_string(),
arguments,
);
}
fn emit_computer_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("computer_call") {
return;
}
let arguments = tool_arguments_from_named_fields(
item,
&["action", "actions", "pending_safety_checks", "status"],
);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
"computer".to_string(),
arguments,
);
}
fn emit_generic_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
name: String,
arguments: String,
) {
self.ensure_started(report_context, out);
let key = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
state.call_id = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.unwrap_or(state.call_id.as_str())
.to_string();
state.name = name;
Self::merge_tool_call_arguments(state, &arguments);
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_missing_tool_result(
&mut self,
report_context: &Value,
@@ -753,6 +886,54 @@ impl OpenAIResponsesProviderState {
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
}
fn emit_generic_tool_result_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
let is_supported_result = matches!(
item_type,
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output"
);
if !is_supported_result {
return;
}
let tool_use_id = item
.get("call_id")
.or_else(|| item.get("tool_call_id"))
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("call_auto_0")
.to_string();
let index =
self.tool_index_for_key(Some(format!("{item_type}:{tool_use_id}")), output_index);
let content = openai_tool_result_content_from_value(
item.get("output")
.or_else(|| item.get("content"))
.or_else(|| item.get("delta")),
);
let name = match item_type {
"local_shell_call_output" => Some("local_shell".to_string()),
"shell_call_output" => Some("shell".to_string()),
"apply_patch_call_output" => Some("apply_patch".to_string()),
"computer_call_output" => Some("computer".to_string()),
_ => item
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned),
};
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
}
fn emit_message_item(
&mut self,
report_context: &Value,
@@ -867,6 +1048,79 @@ impl OpenAIResponsesProviderState {
});
}
fn emit_output_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
final_item: bool,
) {
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => self.emit_tool_call_item(report_context, out, item, output_index),
"function_call_output" => {
self.emit_tool_result_item(report_context, out, item, output_index);
}
"custom_tool_call" => {
self.emit_custom_tool_call_item(report_context, out, item, output_index);
}
"local_shell_call" | "shell_call" => {
self.emit_shell_tool_call_item(report_context, out, item, output_index);
}
"apply_patch_call" => {
self.emit_apply_patch_tool_call_item(report_context, out, item, output_index);
}
"computer_call" => {
self.emit_computer_tool_call_item(report_context, out, item, output_index);
}
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output" => {
self.emit_generic_tool_result_item(report_context, out, item, output_index);
}
"message" => self.emit_message_item(report_context, out, item, output_index),
"reasoning" if final_item => self.emit_reasoning_item(report_context, out, item),
"reasoning" => self.ensure_started(report_context, out),
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
out,
item,
output_index,
final_item,
);
}
"web_search_call" | "file_search_call" | "code_interpreter_call" | "mcp_call" => {
if !final_item {
self.ensure_started(report_context, out);
}
}
_ => out.push(self.unknown_frame(report_context, Value::Object(item.clone()))),
}
}
fn emit_response_output_items(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
response: &Map<String, Value>,
) {
for (output_index, raw_item) in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(item) = raw_item.as_object() else {
continue;
};
self.emit_output_item(report_context, out, item, Some(output_index), true);
}
}
pub fn push_line(
&mut self,
report_context: &Value,
@@ -958,7 +1212,55 @@ impl OpenAIResponsesProviderState {
self.emit_missing_text(report_context, &mut out, key, text);
}
}
"response.reasoning_summary_text.delta" => {
"response.refusal.delta" => {
let piece = value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
if !piece.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_text_delta(report_context, &mut out, key, piece);
}
}
"response.refusal.done" => {
let refusal = value
.get("refusal")
.and_then(Value::as_str)
.or_else(|| {
value
.get("part")
.and_then(Value::as_object)
.and_then(|part| part.get("refusal"))
.and_then(Value::as_str)
})
.unwrap_or_default();
if !refusal.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_missing_text(report_context, &mut out, key, refusal);
}
}
"response.audio.transcript.delta" => {
let piece = value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
if !piece.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_text_delta(report_context, &mut out, key, piece);
}
}
"response.audio.transcript.done" => {
let transcript = value
.get("transcript")
.and_then(Value::as_str)
.or_else(|| value.get("text").and_then(Value::as_str))
.unwrap_or_default();
if !transcript.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_missing_text(report_context, &mut out, key, transcript);
}
}
"response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
let piece = value
.get("delta")
.and_then(Value::as_str)
@@ -983,7 +1285,7 @@ impl OpenAIResponsesProviderState {
});
}
}
"response.reasoning_summary_text.done" => {
"response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
let text = value
.get("text")
.and_then(Value::as_str)
@@ -1024,32 +1326,70 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item, output_index);
}
"reasoning" => {
self.ensure_started(report_context, &mut out);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
output_index,
false,
);
}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
self.emit_output_item(report_context, &mut out, item, output_index, false);
}
"response.custom_tool_call_input.delta" => {
let delta = value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
if delta.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
.or_else(|| value.get("call_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
if state.name.is_empty() {
state.name = value
.get("name")
.and_then(Value::as_str)
.unwrap_or("custom_tool")
.to_string();
}
state.arguments.push_str(delta);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.custom_tool_call_input.done" => {
let input = value
.get("input")
.and_then(Value::as_str)
.unwrap_or_default();
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
.or_else(|| value.get("call_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
if state.name.is_empty() {
state.name = value
.get("name")
.and_then(Value::as_str)
.unwrap_or("custom_tool")
.to_string();
}
let arguments = tool_arguments_from_maybe_json_string(
Some(&Value::String(input.to_string())),
"input",
);
Self::merge_tool_call_arguments(state, &arguments);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_arguments.delta" => {
let delta = value
@@ -1196,32 +1536,28 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item, output_index);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
output_index,
true,
);
}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
}
self.emit_output_item(report_context, &mut out, item, output_index, true);
}
"response.incomplete" => {
let Some(response) = value.get("response").and_then(Value::as_object) else {
return Ok(out);
};
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
self.emit_response_output_items(report_context, &mut out, response);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::Finish {
finish_reason: Some(openai_responses_incomplete_finish_reason(&value)),
usage: canonical_usage_from_openai_usage(response.get("usage")),
},
});
self.finished = true;
}
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
self.ensure_started(report_context, &mut out);
}
event_type if openai_stream_payload_is_terminal_error(&value) => {
self.finished = true;
@@ -1240,67 +1576,13 @@ impl OpenAIResponsesProviderState {
}
out.push(self.unknown_frame(report_context, payload));
}
"response.completed" => {
"response.completed" | "response.done" => {
let Some(response) = value.get("response").and_then(Value::as_object) else {
return Ok(out);
};
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
for (output_index, raw_item) in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(item) = raw_item.as_object() else {
continue;
};
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"message" => {
self.emit_message_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"function_call" => {
self.emit_tool_call_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"function_call_output" => {
self.emit_tool_result_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
Some(output_index),
true,
);
}
_ => {
out.push(
self.unknown_frame(report_context, Value::Object(item.clone())),
);
}
}
}
self.emit_response_output_items(report_context, &mut out, response);
let finish_reason = if self.tool_calls.is_empty() {
Some("stop".to_string())
@@ -1399,6 +1681,7 @@ fn web_search_query_from_arguments(arguments: &str) -> String {
pub struct OpenAIResponsesClientEmitter {
response_id: Option<String>,
model: Option<String>,
created_at: Option<i64>,
message_item_id: Option<String>,
reasoning_item_id: Option<String>,
started: bool,
@@ -1744,13 +2027,28 @@ impl OpenAIResponsesClientEmitter {
}
fn in_progress_response(&self) -> Value {
json!({
let mut response = json!({
"id": self.response_id(),
"object": "response",
"model": self.model(),
"status": "in_progress",
"output": [],
})
});
if let (Some(created_at), Some(response_object)) =
(self.created_at, response.as_object_mut())
{
response_object.insert("created_at".to_string(), Value::from(created_at));
}
response
}
fn ensure_created_at(&mut self) -> i64 {
if let Some(created_at) = self.created_at {
return created_at;
}
let created_at = openai_responses_current_timestamp();
self.created_at = Some(created_at);
created_at
}
fn allocate_output_index(&mut self) -> usize {
@@ -1787,6 +2085,7 @@ impl OpenAIResponsesClientEmitter {
if self.started {
return Ok(Vec::new());
}
self.ensure_created_at();
self.started = true;
let mut out = self.encode_response_event(
"response.created",
@@ -2118,6 +2417,7 @@ impl OpenAIResponsesClientEmitter {
"output_index": output_index,
"item_id": item_id.clone(),
"call_id": item_id.clone(),
"name": name,
"arguments": state.arguments.as_str(),
}),
)?);
@@ -2217,7 +2517,12 @@ impl OpenAIResponsesClientEmitter {
Ok(out)
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
fn terminal_response(
&self,
usage: CanonicalUsage,
status: &str,
incomplete_reason: Option<&str>,
) -> Value {
let mut ordered_output = Vec::new();
let summary = if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
@@ -2336,17 +2641,35 @@ impl OpenAIResponsesClientEmitter {
}
ordered_output.sort_by_key(|(output_index, _)| *output_index);
json!({
let mut response = json!({
"id": self.response_id(),
"object": "response",
"status": "completed",
"status": status,
"model": self.model(),
"output": ordered_output
.into_iter()
.map(|(_, item)| item)
.collect::<Vec<_>>(),
"usage": openai_responses_usage_from_usage(&usage),
})
});
if let Some(reason) = incomplete_reason {
response["incomplete_details"] = json!({ "reason": reason });
}
if let Some(response_object) = response.as_object_mut() {
if let Some(created_at) = self.created_at {
response_object.insert("created_at".to_string(), Value::from(created_at));
}
ensure_modern_openai_responses_response_fields(response_object);
}
response
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
self.terminal_response(usage, "completed", None)
}
fn incomplete_response(&self, usage: CanonicalUsage, reason: &str) -> Value {
self.terminal_response(usage, "incomplete", Some(reason))
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
@@ -2619,7 +2942,10 @@ impl OpenAIResponsesClientEmitter {
self.encode_response_event(event.as_str(), payload)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish { usage, .. } => {
CanonicalStreamEvent::Finish {
finish_reason,
usage,
} => {
if self.finished {
return Ok(Vec::new());
}
@@ -2629,11 +2955,22 @@ impl OpenAIResponsesClientEmitter {
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
let usage = usage.unwrap_or_default();
let (event_type, response) = match finish_reason.as_deref() {
Some("length") => (
"response.incomplete",
self.incomplete_response(usage, "max_output_tokens"),
),
Some("content_filter") => (
"response.incomplete",
self.incomplete_response(usage, "content_filter"),
),
_ => ("response.completed", self.completed_response(usage)),
};
out.extend(self.encode_response_event(
"response.completed",
event_type,
json!({
"type": "response.completed",
"response": self.completed_response(usage),
"type": event_type,
"response": response,
}),
)?);
self.finished = true;
@@ -2706,6 +3043,95 @@ fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
}
}
fn tool_arguments_from_maybe_json_string(value: Option<&Value>, fallback_key: &str) -> String {
match value {
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(_)) => trimmed.to_string(),
Ok(parsed) => single_field_tool_arguments(fallback_key, parsed),
Err(_) => single_field_tool_arguments(fallback_key, Value::String(text.clone())),
}
}
Some(value @ Value::Object(_)) => value.to_string(),
Some(Value::Null) | None => String::new(),
Some(value) => single_field_tool_arguments(fallback_key, value.clone()),
}
}
fn single_field_tool_arguments(key: &str, value: Value) -> String {
let mut arguments = Map::new();
arguments.insert(key.to_string(), value);
Value::Object(arguments).to_string()
}
fn tool_arguments_from_named_fields(item: &Map<String, Value>, field_names: &[&str]) -> String {
let mut arguments = Map::new();
for field_name in field_names {
if let Some(value) = item.get(*field_name) {
arguments.insert((*field_name).to_string(), value.clone());
}
}
if arguments.is_empty() {
String::new()
} else {
Value::Object(arguments).to_string()
}
}
fn openai_responses_stream_event_is_known_noop(event_type: &str) -> bool {
matches!(
event_type,
"response.queued"
| "response.output_text.annotation.added"
| "response.audio.delta"
| "response.audio.done"
| "response.code_interpreter_call.in_progress"
| "response.code_interpreter_call.interpreting"
| "response.code_interpreter_call.completed"
| "response.code_interpreter_call_code.delta"
| "response.code_interpreter_call_code.done"
| "response.file_search_call.in_progress"
| "response.file_search_call.searching"
| "response.file_search_call.completed"
| "response.image_generation_call.in_progress"
| "response.image_generation_call.generating"
| "response.image_generation_call.partial_image"
| "response.image_generation_call.completed"
| "response.mcp_call.in_progress"
| "response.mcp_call.completed"
| "response.mcp_call.failed"
| "response.mcp_call_arguments.delta"
| "response.mcp_call_arguments.done"
| "response.mcp_list_tools.in_progress"
| "response.mcp_list_tools.completed"
| "response.mcp_list_tools.failed"
| "response.web_search_call.in_progress"
| "response.web_search_call.searching"
| "response.web_search_call.completed"
)
}
fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
let reason = payload
.get("response")
.and_then(Value::as_object)
.and_then(|response| response.get("incomplete_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
.unwrap_or_default();
match reason {
"content_filter" => "content_filter",
"tool_calls" | "function_call" => "tool_calls",
_ => "length",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3043,6 +3469,9 @@ mod tests {
assert!(sse.contains("\"response_id\":\"resp_stream_123\""));
assert!(sse.contains("\"item_id\":\"resp_stream_123_msg\""));
assert!(sse.contains("\"text\":\"Hello\""));
assert!(sse.contains("\"output_text\":\"Hello\""));
assert!(sse.contains("\"created_at\":"));
assert!(sse.contains("\"completed_at\":"));
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
}
@@ -3226,6 +3655,53 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_accepts_refusal_events() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.refusal.delta",
"response_id": "resp_refusal_123",
"output_index": 0,
"item_id": "msg_refusal_123",
"content_index": 0,
"delta": "I can't",
})),
)
.expect("refusal delta should parse"),
);
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.refusal.done",
"response_id": "resp_refusal_123",
"output_index": 0,
"item_id": "msg_refusal_123",
"content_index": 0,
"refusal": "I can't help with that.",
})),
)
.expect("refusal done should parse"),
);
assert!(frames.iter().any(|frame| matches!(
&frame.event,
CanonicalStreamEvent::TextDelta(text) if text == "I can't"
)));
assert!(frames.iter().any(|frame| matches!(
&frame.event,
CanonicalStreamEvent::TextDelta(text) if text == " help with that."
)));
}
#[test]
fn openai_responses_provider_state_does_not_duplicate_text_snapshot_deltas() {
let mut state = OpenAIResponsesProviderState::default();
@@ -738,6 +738,34 @@ fn strip_codex_hosted_tool_choice_name_for_backend(
}
}
fn wrap_codex_responses_string_input_for_backend(
body_object: &mut serde_json::Map<String, Value>,
provider_api_format: &str,
) {
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
return;
}
let Some(text) = body_object
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
return;
};
body_object.insert(
"input".to_string(),
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": text,
}],
}]),
);
}
pub fn apply_codex_openai_responses_special_body_edits(
provider_request_body: &mut Value,
provider_type: &str,
@@ -760,6 +788,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
return;
};
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field);
@@ -985,6 +1014,34 @@ mod tests {
assert_eq!(provider_request_body["parallel_tool_calls"], json!(false));
}
#[test]
fn codex_responses_body_edits_wrap_string_input_for_backend() {
let mut provider_request_body = json!({
"input": "hello",
"model": "gpt-5.4"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert_eq!(
provider_request_body["input"],
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "hello"
}]
}])
);
}
#[test]
fn codex_responses_body_edits_preserve_function_tools_for_codex_backend() {
let mut provider_request_body = json!({
@@ -1,10 +1,12 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
formats::openai::shared::{
map_thinking_budget_to_openai_reasoning_effort, OpenAiResponsesReasoningEffort,
},
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments,
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
@@ -182,6 +184,10 @@ pub fn to_raw(
output.insert("reasoning".to_string(), reasoning);
}
output.extend(chat_openai_extension_object_to_responses(
&canonical.extensions,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -200,6 +206,33 @@ pub fn to_raw(
Some(Value::Object(output))
}
fn chat_openai_extension_object_to_responses(
extensions: &BTreeMap<String, Value>,
existing: &Map<String, Value>,
) -> Map<String, Value> {
const RESPONSES_COMPATIBLE_CHAT_FIELDS: &[&str] = &[
"stream",
"store",
"service_tier",
"safety_identifier",
"prompt_cache_key",
];
extensions
.get("openai")
.and_then(Value::as_object)
.map(|object| {
object
.iter()
.filter(|(key, _)| {
RESPONSES_COMPATIBLE_CHAT_FIELDS.contains(&key.as_str())
&& !existing.contains_key(*key)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
@@ -264,6 +297,8 @@ fn claude_system_instruction_to_responses_part(
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut pending_tool_call_ids = VecDeque::new();
for message in &canonical.messages {
let role = match message.role {
CanonicalRole::Assistant => "assistant",
@@ -282,10 +317,13 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
} => {
flush_responses_message(&mut input, role, &mut content);
saw_tool_item = true;
let call_id = responses_tool_call_id(id, &mut next_generated_tool_call_index);
let tool_name = responses_tool_name(name);
pending_tool_call_ids.push_back(call_id.clone());
input.push(json!({
"type": "function_call",
"call_id": id,
"name": name,
"call_id": call_id,
"name": tool_name,
"arguments": canonicalize_tool_arguments(arguments),
}));
}
@@ -302,10 +340,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
output.as_ref(),
content_text.as_deref(),
extensions,
);
)?;
let call_id =
responses_tool_result_call_id(tool_use_id, &mut pending_tool_call_ids)?;
input.push(json!({
"type": "function_call_output",
"call_id": tool_use_id,
"call_id": call_id,
"output": tool_output,
}));
if !extra_user_content.is_empty() {
@@ -360,6 +400,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn responses_tool_call_id(id: &str, next_generated_tool_call_index: &mut usize) -> String {
let trimmed = id.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
let generated = format!("call_auto_{next_generated_tool_call_index}");
*next_generated_tool_call_index += 1;
generated
}
fn responses_tool_result_call_id(
id: &str,
pending_tool_call_ids: &mut VecDeque<String>,
) -> Option<String> {
let trimmed = id.trim();
if !trimmed.is_empty() {
if let Some(position) = pending_tool_call_ids
.iter()
.position(|pending_id| pending_id == trimmed)
{
pending_tool_call_ids.remove(position);
}
return Some(trimmed.to_string());
}
pending_tool_call_ids.pop_front()
}
fn responses_tool_name(name: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
"unknown".to_string()
} else {
trimmed.to_string()
}
}
fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option<u64> {
canonical.generation.max_tokens.map(|max_tokens| {
if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 {
@@ -582,7 +658,7 @@ fn canonical_reasoning_config_to_responses(canonical: &CanonicalRequest) -> Opti
.and_then(|value| value.get("output_config"))
.and_then(|value| value.get("effort"))
.and_then(Value::as_str)
.map(openai_responses_reasoning_effort)
.and_then(openai_responses_reasoning_effort)
.unwrap_or("medium");
object
.entry("effort".to_string())
@@ -608,10 +684,11 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
.get("openai")
.and_then(|value| value.get("reasoning_effort"))
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": openai_responses_reasoning_effort(effort),
})
.and_then(|effort| {
let effort = openai_responses_reasoning_effort(effort)?;
Some(json!({
"effort": effort,
}))
})
})
.or_else(|| {
@@ -623,13 +700,12 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
})
}
fn openai_responses_reasoning_effort(effort: &str) -> &str {
fn openai_responses_reasoning_effort(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"xhigh" | "max" => "xhigh",
"low" => "low",
"medium" => "medium",
"high" => "high",
_ => effort,
"max" => Some("xhigh"),
value => {
OpenAiResponsesReasoningEffort::parse(value).map(OpenAiResponsesReasoningEffort::as_str)
}
}
}
@@ -694,6 +770,9 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
"parameters".to_string(),
responses_tool_parameters_schema(tool.parameters.as_ref()),
);
if let Some(strict) = tool.strict {
out.insert("strict".to_string(), Value::Bool(strict));
}
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -737,16 +816,16 @@ fn responses_tool_result_payload(
output: Option<&Value>,
content_text: Option<&str>,
extensions: &BTreeMap<String, Value>,
) -> (Value, Vec<Value>) {
) -> Option<(Value, Vec<Value>)> {
if is_claude_tool_result(extensions) {
if let Some(Value::Array(parts)) = output {
return claude_tool_result_parts_to_responses_payload(parts);
}
}
(
Some((
responses_tool_result_output(output, content_text),
Vec::new(),
)
))
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
@@ -759,15 +838,64 @@ fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&st
Value::String(non_empty_responses_tool_output(&text))
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec<Value>) {
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_responses_representable)
}
fn claude_tool_result_part_is_openai_responses_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_responses_representable(part_object),
"document" | "file" => claude_document_block_is_openai_responses_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" | "text" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Value, Vec<Value>)> {
let mut output_texts = Vec::new();
let mut extra_user_content = Vec::new();
for part in parts {
let Some(part_object) = part.as_object() else {
output_texts.push("[Claude tool_result non-text content omitted]".to_string());
continue;
};
let part_object = part.as_object()?;
match part_object
.get("type")
.and_then(Value::as_str)
@@ -784,27 +912,31 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec
if let Some(part) = claude_image_block_to_responses_input_part(part_object) {
extra_user_content.push(part);
} else {
output_texts.push(claude_tool_result_media_summary("image", part_object));
return None;
}
}
"document" | "file" => {
if let Some(part) = claude_document_block_to_responses_input_part(part_object) {
if let Some(text) = claude_text_document_block_to_responses_output_text(part_object)
{
if !text.is_empty() {
output_texts.push(text.to_string());
}
} else if let Some(part) =
claude_document_block_to_responses_input_part(part_object)
{
extra_user_content.push(part);
} else {
output_texts.push(claude_tool_result_media_summary("document", part_object));
return None;
}
}
"" => output_texts.push("[Claude tool_result object content omitted]".to_string()),
raw_type => {
output_texts.push(format!("[Claude tool_result {raw_type} content omitted]"))
}
_ => return None,
}
}
(
Some((
Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
extra_user_content,
)
))
}
fn claude_image_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
@@ -863,16 +995,15 @@ fn claude_document_block_to_responses_input_part(block: &Map<String, Value>) ->
Some(Value::Object(part))
}
fn claude_tool_result_media_summary(kind: &str, block: &Map<String, Value>) -> String {
let media_type = block
.get("source")
.and_then(Value::as_object)
.and_then(claude_source_media_type);
match media_type {
Some(media_type) if !media_type.trim().is_empty() => {
format!("[Claude tool_result {kind} content omitted: {media_type}]")
}
_ => format!("[Claude tool_result {kind} content omitted]"),
fn claude_text_document_block_to_responses_output_text(block: &Map<String, Value>) -> Option<&str> {
let source = block.get("source")?.as_object()?;
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => claude_source_str(source, "data"),
_ => None,
}
}
@@ -913,6 +1044,16 @@ mod tests {
CanonicalRole,
};
use serde_json::json;
use std::collections::BTreeMap;
fn claude_tool_result_extensions() -> BTreeMap<String, serde_json::Value> {
let mut extensions = BTreeMap::new();
extensions.insert(
"aether".to_string(),
json!({ "source": "claude_tool_result" }),
);
extensions
}
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
@@ -938,12 +1079,17 @@ mod tests {
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["text"]["format"]["type"], json!("json_object"));
assert_eq!(body["input"][0]["role"], json!("system"));
assert!(body["input"][0]["content"][0]["text"]
assert_eq!(body["instructions"], json!("Please answer in JSON."));
let input = body["input"].as_array().expect("input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], json!("system"));
assert!(input[0]["content"][0]["text"]
.as_str()
.expect("hint text")
.to_ascii_lowercase()
.contains("json"));
assert_eq!(input[1]["role"], json!("user"));
assert_eq!(input[1]["content"][0]["text"], json!("hello"));
}
#[test]
@@ -1003,4 +1149,192 @@ mod tests {
assert_eq!(body["input"][0]["call_id"], "call_empty");
assert_eq!(body["input"][0]["output"], "(empty)");
}
#[test]
fn responses_request_replaces_empty_tool_call_identifiers() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![CanonicalContentBlock::ToolUse {
id: " ".to_string(),
name: "".to_string(),
input: json!({"q": "rust"}),
extensions: Default::default(),
}],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!({"ok": true})),
content_text: None,
is_error: false,
extensions: Default::default(),
}],
extensions: Default::default(),
},
],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"].as_array().expect("input").len(), 2);
assert_eq!(body["input"][0]["type"], "function_call");
assert_eq!(body["input"][0]["call_id"], "call_auto_0");
assert_eq!(body["input"][0]["name"], "unknown");
assert_eq!(body["input"][0]["arguments"], "{\"q\":\"rust\"}");
assert_eq!(body["input"][1]["type"], "function_call_output");
assert_eq!(body["input"][1]["call_id"], "call_auto_0");
}
#[test]
fn responses_request_assigns_empty_tool_result_identifiers_from_pending_tool_calls_in_order() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![
CanonicalContentBlock::ToolUse {
id: "call_a".to_string(),
name: "lookup_a".to_string(),
input: json!({"q": "a"}),
extensions: Default::default(),
},
CanonicalContentBlock::ToolUse {
id: "call_b".to_string(),
name: "lookup_b".to_string(),
input: json!({"q": "b"}),
extensions: Default::default(),
},
],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![
CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!("result a")),
content_text: None,
is_error: false,
extensions: Default::default(),
},
CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!("result b")),
content_text: None,
is_error: false,
extensions: Default::default(),
},
],
extensions: Default::default(),
},
],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"][0]["call_id"], "call_a");
assert_eq!(body["input"][1]["call_id"], "call_b");
assert_eq!(body["input"][2]["call_id"], "call_a");
assert_eq!(body["input"][2]["output"], "result a");
assert_eq!(body["input"][3]["call_id"], "call_b");
assert_eq!(body["input"][3]["output"], "result b");
}
#[test]
fn responses_request_rejects_orphan_empty_tool_result_identifier() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!({"ok": true})),
content_text: None,
is_error: false,
extensions: Default::default(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
#[test]
fn responses_request_preserves_claude_text_document_tool_result_content() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_doc".to_string(),
name: None,
output: Some(json!([
{"type": "text", "text": "preview"},
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "document body"
}
}
])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"][0]["type"], "function_call_output");
assert_eq!(body["input"][0]["output"], "preview\n\ndocument body");
assert!(!body.to_string().contains("content omitted"));
}
#[test]
fn responses_request_rejects_unrepresentable_claude_tool_result_blocks() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_img".to_string(),
name: None,
output: Some(json!([{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
}
@@ -1,13 +1,16 @@
use std::collections::BTreeMap;
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_content_block_to_openai_responses_part,
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
flush_openai_responses_message_item, is_openai_thinking_block, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
@@ -42,11 +45,19 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
Some(CanonicalStopReason::ToolUse)
} else {
match body.get("status").and_then(Value::as_str) {
Some("incomplete") => Some(CanonicalStopReason::MaxTokens),
Some("incomplete") => Some(openai_responses_incomplete_stop_reason(body)),
Some("failed") => Some(CanonicalStopReason::Unknown),
_ => Some(CanonicalStopReason::EndTurn),
}
};
let mut extensions = openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
);
if let Some(raw_status) = body.get("status").cloned() {
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.insert("raw_status".to_string(), raw_status);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -68,13 +79,23 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
),
extensions,
})
}
fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> CanonicalStopReason {
match body
.get("incomplete_details")
.and_then(Value::as_object)
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
{
Some("content_filter") => CanonicalStopReason::ContentFiltered,
Some("tool_calls") | Some("function_call") => CanonicalStopReason::ToolUse,
_ => CanonicalStopReason::MaxTokens,
}
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: bool) -> Value {
let mut response = Map::new();
let response_id = canonical.id.replace("chatcmpl", "resp");
@@ -82,6 +103,20 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
response.insert("object".to_string(), Value::String("response".to_string()));
response.insert("status".to_string(), Value::String("completed".to_string()));
response.insert("model".to_string(), Value::String(canonical.model.clone()));
if let Some(raw_status) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_status"))
.cloned()
{
response.insert("status".to_string(), raw_status);
}
let mut output = Vec::new();
let mut message_content = Vec::new();
@@ -123,8 +158,16 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
CanonicalContentBlock::Thinking {
text,
encrypted_content,
extensions,
..
} => {
let encrypted_content = encrypted_content
.as_ref()
.filter(|value| !value.is_empty())
.filter(|_| is_openai_thinking_block(extensions));
if text.trim().is_empty() && encrypted_content.is_none() {
continue;
}
flush_openai_responses_message_item(
&mut output,
&mut message_content,
@@ -138,9 +181,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
Value::String(format!("{}_rs_{}", response_id, output.len())),
);
item.insert("status".to_string(), Value::String("completed".to_string()));
if let Some(encrypted_content) =
encrypted_content.as_ref().filter(|value| !value.is_empty())
{
if let Some(encrypted_content) = encrypted_content {
item.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.clone()),
@@ -272,19 +313,100 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
response.insert("service_tier".to_string(), service_tier);
}
}
response.extend(namespace_extension_object(
let mut extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&response,
));
response.extend(namespace_extension_object(
);
extension_fields.remove("raw_status");
response.extend(extension_fields);
let mut legacy_extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&response,
));
);
legacy_extension_fields.remove("raw_status");
response.extend(legacy_extension_fields);
ensure_modern_openai_responses_response_fields(&mut response);
Value::Object(response)
}
pub(crate) fn ensure_modern_openai_responses_response_fields(
response: &mut Map<String, Value>,
) -> bool {
let mut changed = false;
if !response
.get("output")
.is_some_and(|value| matches!(value, Value::Array(_)))
{
response.insert("output".to_string(), Value::Array(Vec::new()));
changed = true;
}
if !response.contains_key("created_at") {
let created_at = response
.get("created")
.and_then(openai_responses_timestamp_value)
.unwrap_or_else(openai_responses_current_timestamp);
response.insert("created_at".to_string(), Value::from(created_at));
changed = true;
}
if response
.get("status")
.and_then(Value::as_str)
.is_none_or(|status| status == "completed")
&& !response.contains_key("completed_at")
{
let completed_at = response
.get("created_at")
.and_then(openai_responses_timestamp_value)
.unwrap_or_else(openai_responses_current_timestamp);
response.insert("completed_at".to_string(), Value::from(completed_at));
changed = true;
}
if !response.contains_key("output_text") {
let output_text = openai_responses_output_text_from_output(response.get("output"));
response.insert("output_text".to_string(), Value::String(output_text));
changed = true;
}
changed
}
pub(crate) fn openai_responses_output_text_from_output(output: Option<&Value>) -> String {
output
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.flat_map(|item| {
item.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
})
.filter_map(|part| {
let part = part.as_object()?;
matches!(
part.get("type").and_then(Value::as_str),
Some("output_text" | "text")
)
.then(|| part.get("text").and_then(Value::as_str).unwrap_or_default())
})
.collect::<String>()
}
pub(crate) fn openai_responses_current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
}
fn openai_responses_timestamp_value(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
}
fn image_block_is_generation_call(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
@@ -379,6 +501,101 @@ mod tests {
assert_eq!(body["output"][0]["status"], "completed");
assert_eq!(body["output"][0]["action"]["type"], "search");
assert_eq!(body["output"][0]["action"]["query"], "today tech");
assert_eq!(body["output_text"], "");
assert!(body["created_at"].as_i64().is_some());
assert!(body["completed_at"].as_i64().is_some());
}
#[test]
fn responses_response_builder_emits_modern_output_text_and_preserves_source_fields() {
let mut extensions = BTreeMap::new();
extensions.insert(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
json!({
"created_at": 111,
"completed_at": 222,
"output_text": "source text",
"conversation": {"id": "conv_123"}
}),
);
let response = CanonicalResponse {
id: "resp_text".to_string(),
model: "gpt-5".to_string(),
content: vec![CanonicalContentBlock::Text {
text: "generated text".to_string(),
extensions: BTreeMap::new(),
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions,
};
let body = to_raw(&response, &json!({}), false);
assert_eq!(body["output_text"], "source text");
assert_eq!(body["created_at"], 111);
assert_eq!(body["completed_at"], 222);
assert_eq!(body["conversation"]["id"], "conv_123");
}
#[test]
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
let body = json!({
"id": "resp_test",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [],
"encrypted_content": "openai-opaque"
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(matches!(
canonical.content.first(),
Some(CanonicalContentBlock::Thinking {
text,
encrypted_content,
..
}) if text.is_empty() && encrypted_content.as_deref() == Some("openai-opaque")
));
let rebuilt = to_raw(&canonical, &json!({}), false);
assert_eq!(rebuilt["output"][0]["type"], "reasoning");
assert_eq!(
rebuilt["output"][0]["encrypted_content"],
json!("openai-opaque")
);
}
#[test]
fn responses_response_builder_does_not_emit_claude_redacted_as_openai_encrypted_content() {
let mut extensions = BTreeMap::new();
extensions.insert("aether".to_string(), json!({"source": "claude_thinking"}));
let response = CanonicalResponse {
id: "msg_claude".to_string(),
model: "claude-sonnet".to_string(),
content: vec![CanonicalContentBlock::Thinking {
text: String::new(),
signature: None,
encrypted_content: Some("{\"type\":\"redacted_thinking\",\"v\":5}".to_string()),
extensions,
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
assert!(body["output"].as_array().expect("output").is_empty());
assert!(!body.to_string().contains("encrypted_content"));
}
#[test]
@@ -2,6 +2,51 @@ use serde_json::{Map, Value};
use crate::formats::shared::model_directives::ReasoningEffort;
macro_rules! define_openai_reasoning_effort {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name {
None,
Minimal,
Low,
Medium,
High,
XHigh,
}
impl $name {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
};
}
define_openai_reasoning_effort!(OpenAiChatReasoningEffort);
define_openai_reasoning_effort!(OpenAiResponsesReasoningEffort);
#[deprecated(note = "use OpenAiChatReasoningEffort or OpenAiResponsesReasoningEffort")]
pub type OpenAiReasoningEffort = OpenAiChatReasoningEffort;
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
Some(Value::String(value)) if !value.trim().is_empty() => {
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ pub enum ModelOverride {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
@@ -24,6 +26,8 @@ pub enum ReasoningEffort {
impl ReasoningEffort {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
@@ -35,6 +39,8 @@ impl ReasoningEffort {
pub fn as_openai_chat_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -44,6 +50,8 @@ impl ReasoningEffort {
pub fn as_openai_responses_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -53,6 +61,7 @@ impl ReasoningEffort {
pub fn as_claude_output_value(self) -> &'static str {
match self {
Self::None | Self::Minimal => "low",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -63,7 +72,7 @@ impl ReasoningEffort {
pub fn as_gemini_level_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::None | Self::Minimal | Self::Low => "low",
Self::Medium => "medium",
Self::High | Self::XHigh | Self::Max => "high",
}
@@ -71,6 +80,8 @@ impl ReasoningEffort {
pub fn thinking_budget_tokens(self) -> u64 {
match self {
Self::None => 0,
Self::Minimal => 512,
Self::Low => 1280,
Self::Medium => 2048,
Self::High => 4096,
@@ -2,19 +2,17 @@ use serde_json::Value;
use crate::formats::shared::AiSurfaceFinalizeError;
pub fn map_claude_stop_reason(
stop_reason: Option<&str>,
has_tool_calls: bool,
) -> Option<&'static str> {
pub fn map_claude_stop_reason(stop_reason: Option<&str>, has_tool_calls: bool) -> Option<String> {
let mapped = match stop_reason {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some("pause_turn") => Some("stop"),
Some("end_turn") | Some("stop_sequence") => Some("stop".to_string()),
Some("max_tokens") => Some("length".to_string()),
Some("tool_use") => Some("tool_calls".to_string()),
Some("pause_turn") => Some("stop".to_string()),
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
_ => None,
};
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
Some("tool_calls")
if has_tool_calls && mapped.as_deref().is_none_or(|value| value == "stop") {
Some("tool_calls".to_string())
} else {
mapped
}
@@ -150,6 +150,10 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
&mut provider_request_body,
provider_api_format,
);
strip_openai_responses_input_content_cache_control(
&mut provider_request_body,
provider_api_format,
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"))
@@ -246,9 +250,61 @@ pub fn build_standard_request_body_from_canonical_with_model_directives(
None,
);
}
strip_openai_responses_input_content_cache_control(
&mut provider_request_body,
provider_api_format,
);
Some(provider_request_body)
}
fn strip_openai_responses_input_content_cache_control(
provider_request_body: &mut Value,
provider_api_format: &str,
) {
if !matches!(
aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str(),
"openai:responses" | "openai:responses:compact"
) {
return;
}
let Some(input) = provider_request_body.get_mut("input") else {
return;
};
strip_responses_input_items_content_cache_control(input);
}
fn strip_responses_input_items_content_cache_control(value: &mut Value) {
match value {
Value::Array(items) => {
for item in items {
strip_responses_input_items_content_cache_control(item);
}
}
Value::Object(item) => {
if let Some(content) = item.get_mut("content") {
strip_responses_content_cache_control(content);
}
}
_ => {}
}
}
fn strip_responses_content_cache_control(content: &mut Value) {
match content {
Value::Array(parts) => {
for part in parts {
if let Some(part) = part.as_object_mut() {
part.remove("cache_control");
}
}
}
Value::Object(part) => {
part.remove("cache_control");
}
_ => {}
}
}
pub fn normalize_standard_request_to_openai_chat_request(
body_json: &Value,
client_api_format: &str,
@@ -1236,6 +1292,98 @@ mod tests {
}
}
#[test]
fn standard_openai_responses_strips_content_cache_control_after_body_rules() {
let request = json!({
"model": "gpt-5.1",
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}],
"prompt_cache_key": "cache_123"
});
let body_rules = json!([
{
"action": "set",
"path": "input[0].content[0].cache_control",
"value": {"type": "ephemeral"}
}
]);
let converted = build_standard_request_body(
&request,
"openai:responses",
"gpt-5.1",
"openai",
"openai:responses",
"/v1/responses",
false,
Some(&body_rules),
None,
)
.expect("responses request should build");
assert_eq!(converted["prompt_cache_key"], "cache_123");
assert!(!converted["input"].to_string().contains("cache_control"));
}
#[test]
fn standard_codex_responses_derives_prompt_cache_key_before_stripping_cache_control() {
fn claude_request(user_text: &str) -> Value {
json!({
"model": "claude-sonnet",
"system": [{
"type": "text",
"text": "stable system brief",
"cache_control": {"type": "ephemeral"}
}],
"messages": [{
"role": "user",
"content": [{"type": "text", "text": user_text}]
}],
"max_tokens": 128
})
}
let body_a = claude_request("new turn A");
let body_b = claude_request("new turn B");
let converted_a = build_standard_request_body(
&body_a,
"claude:messages",
"gpt-5.4",
"codex",
"openai:responses",
"/v1/messages",
true,
None,
Some("key-a"),
)
.expect("claude to codex responses request should build");
let converted_b = build_standard_request_body(
&body_b,
"claude:messages",
"gpt-5.4",
"codex",
"openai:responses",
"/v1/messages",
true,
None,
Some("key-a"),
)
.expect("claude to codex responses request should build");
assert!(converted_a["prompt_cache_key"]
.as_str()
.is_some_and(|value| !value.trim().is_empty()));
assert_eq!(
converted_a["prompt_cache_key"],
converted_b["prompt_cache_key"]
);
assert!(!converted_a.to_string().contains("cache_control"));
assert!(!converted_b.to_string().contains("cache_control"));
}
#[test]
fn builds_openai_chat_request_from_claude_chat_source() {
let request = json!({
@@ -19,6 +19,93 @@ pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
serde_json::from_str(data_line).ok()
}
pub fn unsupported_stream_event_message(payload: &Value) -> String {
const BASE_MESSAGE: &str = "Unsupported provider stream event cannot be converted losslessly";
match unsupported_stream_event_diagnostic(payload) {
Some(diagnostic) if !diagnostic.is_empty() => format!("{BASE_MESSAGE}: {diagnostic}"),
_ => BASE_MESSAGE.to_string(),
}
}
fn unsupported_stream_event_diagnostic(payload: &Value) -> Option<String> {
let mut details = Vec::new();
if let Some((path, value)) = unsupported_stream_event_primary_field(payload) {
details.push(format!("field {path} = {value}"));
} else if let Some(path) = unsupported_stream_event_single_field(payload) {
details.push(format!("field {path} is unsupported"));
}
if let Some(fields) = unsupported_stream_event_field_list(payload) {
details.push(format!("fields: {fields}"));
}
if details.is_empty() {
None
} else {
Some(details.join("; "))
}
}
fn unsupported_stream_event_primary_field(payload: &Value) -> Option<(&'static str, String)> {
const STRING_FIELD_PATHS: &[(&str, &str)] = &[
("$.item.type", "/item/type"),
("$.content_block.type", "/content_block/type"),
("$.delta.type", "/delta/type"),
("$.part.type", "/part/type"),
("$.payload.type", "/payload/type"),
("$.type", "/type"),
("$.event", "/event"),
];
STRING_FIELD_PATHS
.iter()
.find_map(|(display_path, pointer)| {
payload
.pointer(pointer)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(|value| (*display_path, json!(value.trim()).to_string()))
})
}
fn unsupported_stream_event_single_field(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.len() != 1 {
return None;
}
object.keys().next().map(|key| json_path_key(key))
}
fn unsupported_stream_event_field_list(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.is_empty() {
return None;
}
let fields = object
.keys()
.take(8)
.map(|key| key.as_str())
.collect::<Vec<_>>()
.join(", ");
if object.len() > 8 {
Some(format!("{fields}, ..."))
} else {
Some(fields)
}
}
fn json_path_key(key: &str) -> String {
if !key.is_empty()
&& key.chars().enumerate().all(|(index, ch)| {
ch == '_' || ch.is_ascii_alphabetic() || (index > 0 && ch.is_ascii_digit())
})
{
format!("$.{key}")
} else {
format!("$[{}]", json!(key))
}
}
pub fn resolve_identity(
response_id: Option<&str>,
model: Option<&str>,
@@ -110,26 +197,27 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
}
pub fn openai_stream_payload_is_terminal_error(payload: &Value) -> bool {
let response = payload.get("response").and_then(Value::as_object);
if payload.get("error").is_some_and(|error| !error.is_null())
|| response
.and_then(|response| response.get("error"))
.is_some_and(|error| !error.is_null())
{
return true;
}
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if payload.get("error").is_some() {
return true;
}
if matches!(
event_type,
"error" | "response.failed" | "response.incomplete"
) {
if matches!(event_type, "error" | "response.failed") {
return true;
}
payload
.get("response")
.and_then(Value::as_object)
response
.and_then(|response| response.get("status"))
.and_then(Value::as_str)
.is_some_and(|status| matches!(status, "failed" | "incomplete"))
.is_some_and(|status| status == "failed")
}
pub fn openai_stream_terminal_error_body(payload: &Value) -> Option<Value> {
@@ -699,3 +787,38 @@ fn inclusive_total_tokens_from_usage(usage: &CanonicalUsage, input_tokens: u64)
input_tokens.saturating_add(usage.output_tokens)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
openai_stream_payload_is_terminal_error, openai_stream_terminal_error_body,
openai_stream_terminal_error_message,
};
#[test]
fn completed_openai_responses_payload_with_null_error_is_not_terminal_error() {
let payload = json!({
"type": "response.completed",
"response": {
"id": "resp_123",
"object": "response",
"status": "completed",
"error": null,
"incomplete_details": null,
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
},
"error": null
});
assert!(!openai_stream_payload_is_terminal_error(&payload));
assert!(openai_stream_terminal_error_body(&payload).is_none());
assert!(openai_stream_terminal_error_message(&payload).is_none());
}
}

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