mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(codex): add provider outbound policy boundary
This commit is contained in:
@@ -5,72 +5,17 @@ use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::outbound_request_policy::{
|
||||
ProviderOutboundRequestContext, ProviderOutboundRequestIdentityScope,
|
||||
ProviderOutboundRequestMutationScope, ProviderOutboundRequestPolicy,
|
||||
ProviderOutboundRequestPolicyReason, ProviderOutboundRequestPolicyResult,
|
||||
};
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
pub const CODEX_FINGERPRINT_CONFIG_NAMESPACE: &str = "codex";
|
||||
pub const CODEX_FINGERPRINT_ENABLED_CONFIG_KEY: &str = "fingerprint_convergence_enabled";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexFingerprintConvergenceContext {
|
||||
logical_turn_id: String,
|
||||
original_turn_id: Option<String>,
|
||||
original_client_session_id: Option<String>,
|
||||
original_prompt_cache_key: Option<String>,
|
||||
turn_started_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
impl CodexFingerprintConvergenceContext {
|
||||
pub fn new(logical_turn_id: impl Into<String>, turn_started_at_unix_ms: u64) -> Self {
|
||||
Self {
|
||||
logical_turn_id: logical_turn_id.into().trim().to_string(),
|
||||
original_turn_id: None,
|
||||
original_client_session_id: None,
|
||||
original_prompt_cache_key: None,
|
||||
turn_started_at_unix_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_original_turn_id(mut self, original_turn_id: impl Into<String>) -> Self {
|
||||
self.original_turn_id = non_empty_owned(original_turn_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_original_client_session_id(
|
||||
mut self,
|
||||
original_client_session_id: impl Into<String>,
|
||||
) -> Self {
|
||||
self.original_client_session_id = non_empty_owned(original_client_session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_original_prompt_cache_key(
|
||||
mut self,
|
||||
original_prompt_cache_key: impl Into<String>,
|
||||
) -> Self {
|
||||
self.original_prompt_cache_key = non_empty_owned(original_prompt_cache_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn logical_turn_id(&self) -> &str {
|
||||
self.logical_turn_id.as_str()
|
||||
}
|
||||
|
||||
pub fn original_turn_id(&self) -> Option<&str> {
|
||||
self.original_turn_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn original_client_session_id(&self) -> Option<&str> {
|
||||
self.original_client_session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn original_prompt_cache_key(&self) -> Option<&str> {
|
||||
self.original_prompt_cache_key.as_deref()
|
||||
}
|
||||
|
||||
pub fn turn_started_at_unix_ms(&self) -> u64 {
|
||||
self.turn_started_at_unix_ms
|
||||
}
|
||||
}
|
||||
pub type CodexFingerprintConvergenceContext = ProviderOutboundRequestContext;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CodexConvergedFingerprint {
|
||||
@@ -125,36 +70,82 @@ pub fn apply_codex_fingerprint_convergence_with_context(
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &mut Value,
|
||||
) -> bool {
|
||||
let is_responses = aether_ai_formats::is_openai_responses_format(provider_api_format);
|
||||
let is_live = aether_ai_formats::api_format_alias_matches(provider_api_format, "codex:live");
|
||||
// Convergence is a Codex provider policy, independent of whether the key
|
||||
// uses OAuth, an API key, or another ordinary auth channel. Agent Identity
|
||||
// uses a separate signed-identity protocol and is excluded here.
|
||||
apply_codex_fingerprint_convergence_policy(
|
||||
transport,
|
||||
provider_api_format,
|
||||
context,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
)
|
||||
.was_applied()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_codex_fingerprint_convergence_policy(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
context: &ProviderOutboundRequestContext,
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &mut Value,
|
||||
) -> ProviderOutboundRequestPolicyResult {
|
||||
let policy = ProviderOutboundRequestPolicy::CodexFingerprintConvergence;
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
|| crate::agent_identity::is_codex_agent_identity_transport(transport)
|
||||
|| (!is_responses && !is_live)
|
||||
|| is_responses
|
||||
&& aether_ai_formats::openai_responses_request_operation(
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
) == Some(aether_ai_formats::OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
|| !codex_fingerprint_convergence_enabled(
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.provider.config.as_ref(),
|
||||
)
|
||||
|| !provider_request_body.is_object()
|
||||
{
|
||||
return false;
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::ProviderTypeMismatch,
|
||||
);
|
||||
}
|
||||
if crate::agent_identity::is_codex_agent_identity_transport(transport) {
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::AgentIdentityExcluded,
|
||||
);
|
||||
}
|
||||
|
||||
let is_responses = aether_ai_formats::is_openai_responses_format(provider_api_format);
|
||||
let is_live = aether_ai_formats::api_format_alias_matches(provider_api_format, "codex:live");
|
||||
if !is_responses && !is_live {
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::UnsupportedApiFormat,
|
||||
);
|
||||
}
|
||||
if is_responses
|
||||
&& aether_ai_formats::openai_responses_request_operation(
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
) == Some(aether_ai_formats::OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
{
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::CompactOperationExcluded,
|
||||
);
|
||||
}
|
||||
if !codex_fingerprint_convergence_enabled(
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.provider.config.as_ref(),
|
||||
) {
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::Disabled,
|
||||
);
|
||||
}
|
||||
if !provider_request_body.is_object() {
|
||||
return ProviderOutboundRequestPolicyResult::skipped(
|
||||
policy,
|
||||
ProviderOutboundRequestPolicyReason::RequestBodyNotObject,
|
||||
);
|
||||
}
|
||||
|
||||
let auth_identity = aether_ai_formats::parse_codex_auth_identity(
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
let account_seed = resolve_codex_account_seed(&auth_identity, transport.key.id.as_str());
|
||||
let (account_seed, identity_scope) =
|
||||
resolve_codex_account_seed_with_scope(&auth_identity, transport.key.id.as_str());
|
||||
// Only namespace a cache key that survived all provider-body conversion and
|
||||
// routing rules. The client-side value in `context` is a retry signal, not
|
||||
// permission to resurrect a field that the terminal body deliberately
|
||||
@@ -178,7 +169,15 @@ pub fn apply_codex_fingerprint_convergence_with_context(
|
||||
if is_responses {
|
||||
apply_converged_client_metadata(provider_request_body, &fingerprint);
|
||||
}
|
||||
true
|
||||
ProviderOutboundRequestPolicyResult::applied(
|
||||
policy,
|
||||
if is_responses {
|
||||
ProviderOutboundRequestMutationScope::HeadersAndBody
|
||||
} else {
|
||||
ProviderOutboundRequestMutationScope::Headers
|
||||
},
|
||||
identity_scope,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -242,11 +241,6 @@ fn resolve_converged_fingerprint_with_prompt_cache(
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_owned(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn current_unix_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -261,32 +255,62 @@ fn normalized_identity_part(value: Option<&str>) -> Option<String> {
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_codex_account_seed(
|
||||
identity: &aether_ai_formats::CodexAuthIdentity,
|
||||
fallback_key_id: &str,
|
||||
) -> String {
|
||||
let fingerprint = normalized_identity_part(identity.codex_identity_fingerprint.as_deref())
|
||||
.or_else(|| {
|
||||
aether_oauth::provider::providers::derive_codex_identity_fingerprint(
|
||||
identity.account_id.as_deref(),
|
||||
identity.account_user_id.as_deref(),
|
||||
identity.user_id.as_deref(),
|
||||
identity.email.as_deref(),
|
||||
)
|
||||
});
|
||||
if let Some(fingerprint) = fingerprint {
|
||||
return format!("persisted:v1:{fingerprint}");
|
||||
resolve_codex_account_seed_with_scope(identity, fallback_key_id).0
|
||||
}
|
||||
|
||||
fn resolve_codex_account_seed_with_scope(
|
||||
identity: &aether_ai_formats::CodexAuthIdentity,
|
||||
fallback_key_id: &str,
|
||||
) -> (String, ProviderOutboundRequestIdentityScope) {
|
||||
if let Some(fingerprint) =
|
||||
normalized_identity_part(identity.codex_identity_fingerprint.as_deref())
|
||||
{
|
||||
return (
|
||||
format!("persisted:v1:{fingerprint}"),
|
||||
ProviderOutboundRequestIdentityScope::PersistedFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
let account = normalized_identity_part(identity.account_id.as_deref());
|
||||
let member = normalized_identity_part(identity.account_user_id.as_deref())
|
||||
.or_else(|| normalized_identity_part(identity.user_id.as_deref()))
|
||||
.or_else(|| normalized_identity_part(identity.email.as_deref()));
|
||||
if let Some(fingerprint) = aether_oauth::provider::providers::derive_codex_identity_fingerprint(
|
||||
account.as_deref(),
|
||||
member.as_deref(),
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
let scope = if account.is_some() {
|
||||
ProviderOutboundRequestIdentityScope::AccountMember
|
||||
} else {
|
||||
ProviderOutboundRequestIdentityScope::Member
|
||||
};
|
||||
return (format!("persisted:v1:{fingerprint}"), scope);
|
||||
}
|
||||
|
||||
match (account, member) {
|
||||
(Some(account), Some(member)) => format!("account-member:v1:{account}\0{member}"),
|
||||
(None, Some(member)) => format!("member:v1:{member}"),
|
||||
(Some(account), None) => format!("account:v1:{account}"),
|
||||
(None, None) => format!("key:v1:{}", fallback_key_id.trim()),
|
||||
(Some(account), Some(member)) => (
|
||||
format!("account-member:v1:{account}\0{member}"),
|
||||
ProviderOutboundRequestIdentityScope::AccountMember,
|
||||
),
|
||||
(None, Some(member)) => (
|
||||
format!("member:v1:{member}"),
|
||||
ProviderOutboundRequestIdentityScope::Member,
|
||||
),
|
||||
(Some(account), None) => (
|
||||
format!("account:v1:{account}"),
|
||||
ProviderOutboundRequestIdentityScope::Account,
|
||||
),
|
||||
(None, None) => (
|
||||
format!("key:v1:{}", fallback_key_id.trim()),
|
||||
ProviderOutboundRequestIdentityScope::Key,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,5 +1225,131 @@ mod tests {
|
||||
));
|
||||
assert_eq!(headers, original_headers);
|
||||
assert_eq!(body, original_body);
|
||||
|
||||
let context = ProviderOutboundRequestContext::new("logical-turn", 1_700_000_000_123);
|
||||
let result = apply_codex_fingerprint_convergence_policy(
|
||||
&transport,
|
||||
"openai:responses",
|
||||
&context,
|
||||
&mut headers,
|
||||
&mut body,
|
||||
);
|
||||
assert_eq!(
|
||||
result.reason,
|
||||
ProviderOutboundRequestPolicyReason::AgentIdentityExcluded
|
||||
);
|
||||
assert_eq!(headers, original_headers);
|
||||
assert_eq!(body, original_body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_result_reports_applied_scopes_without_identity_values() {
|
||||
let transport = sample_transport();
|
||||
let context = ProviderOutboundRequestContext::new("logical-turn", 1_700_000_000_123);
|
||||
let mut headers = BTreeMap::new();
|
||||
let mut body = json!({"model": "gpt-5.4"});
|
||||
|
||||
let result = apply_codex_fingerprint_convergence_policy(
|
||||
&transport,
|
||||
"openai:responses",
|
||||
&context,
|
||||
&mut headers,
|
||||
&mut body,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
ProviderOutboundRequestPolicyResult {
|
||||
policy: ProviderOutboundRequestPolicy::CodexFingerprintConvergence,
|
||||
outcome:
|
||||
crate::outbound_request_policy::ProviderOutboundRequestPolicyOutcome::Applied,
|
||||
reason: ProviderOutboundRequestPolicyReason::Applied,
|
||||
mutation_scope: Some(ProviderOutboundRequestMutationScope::HeadersAndBody),
|
||||
identity_scope: Some(ProviderOutboundRequestIdentityScope::Account),
|
||||
}
|
||||
);
|
||||
let serialized = serde_json::to_value(result).expect("serialize policy result");
|
||||
let serialized = serialized.as_object().expect("policy result object");
|
||||
assert_eq!(serialized.len(), 5);
|
||||
assert!(!serialized.contains_key("installation_id"));
|
||||
assert!(!serialized.contains_key("session_id"));
|
||||
assert!(!serialized.contains_key("turn_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_result_distinguishes_codex_skip_reasons_without_mutation() {
|
||||
let context = ProviderOutboundRequestContext::new("logical-turn", 1_700_000_000_123);
|
||||
let original_headers = BTreeMap::from([("x-custom".to_string(), "preserve".to_string())]);
|
||||
let original_body = json!({"model": "gpt-5.4"});
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"provider_type_mismatch",
|
||||
"openai",
|
||||
Some(json!({"codex": {"fingerprint_convergence_enabled": true}})),
|
||||
"openai:responses",
|
||||
original_body.clone(),
|
||||
ProviderOutboundRequestPolicyReason::ProviderTypeMismatch,
|
||||
),
|
||||
(
|
||||
"unsupported_api_format",
|
||||
"codex",
|
||||
Some(json!({"codex": {"fingerprint_convergence_enabled": true}})),
|
||||
"openai:chat",
|
||||
original_body.clone(),
|
||||
ProviderOutboundRequestPolicyReason::UnsupportedApiFormat,
|
||||
),
|
||||
(
|
||||
"compact_operation",
|
||||
"codex",
|
||||
Some(json!({"codex": {"fingerprint_convergence_enabled": true}})),
|
||||
"openai:responses",
|
||||
json!({"model": "gpt-5.4", "input": [{"type": "compaction_trigger"}]}),
|
||||
ProviderOutboundRequestPolicyReason::CompactOperationExcluded,
|
||||
),
|
||||
(
|
||||
"disabled",
|
||||
"codex",
|
||||
None,
|
||||
"openai:responses",
|
||||
original_body.clone(),
|
||||
ProviderOutboundRequestPolicyReason::Disabled,
|
||||
),
|
||||
(
|
||||
"request_body_not_object",
|
||||
"codex",
|
||||
Some(json!({"codex": {"fingerprint_convergence_enabled": true}})),
|
||||
"openai:responses",
|
||||
json!(["not-an-object"]),
|
||||
ProviderOutboundRequestPolicyReason::RequestBodyNotObject,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, provider_type, config, api_format, body, expected_reason) in cases {
|
||||
let mut transport = sample_transport();
|
||||
transport.provider.provider_type = provider_type.to_string();
|
||||
transport.provider.config = config;
|
||||
let mut headers = original_headers.clone();
|
||||
let mut request_body = body.clone();
|
||||
|
||||
let result = apply_codex_fingerprint_convergence_policy(
|
||||
&transport,
|
||||
api_format,
|
||||
&context,
|
||||
&mut headers,
|
||||
&mut request_body,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.outcome,
|
||||
crate::outbound_request_policy::ProviderOutboundRequestPolicyOutcome::Skipped,
|
||||
"case={name}"
|
||||
);
|
||||
assert_eq!(result.reason, expected_reason, "case={name}");
|
||||
assert_eq!(result.mutation_scope, None, "case={name}");
|
||||
assert_eq!(result.identity_scope, None, "case={name}");
|
||||
assert_eq!(headers, original_headers, "case={name}");
|
||||
assert_eq!(request_body, body, "case={name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod kiro;
|
||||
mod network;
|
||||
pub mod oauth_refresh;
|
||||
mod openai_image;
|
||||
mod outbound_request_policy;
|
||||
pub mod policy;
|
||||
pub mod provider_types;
|
||||
mod request_body;
|
||||
@@ -120,6 +121,13 @@ pub use openai_image::{
|
||||
openai_image_transport_unsupported_reason, resolve_openai_image_auth,
|
||||
ProviderOpenAiImageHeadersInput,
|
||||
};
|
||||
pub use outbound_request_policy::{
|
||||
apply_provider_outbound_request_policies, ProviderOutboundRequestContext,
|
||||
ProviderOutboundRequestIdentityScope, ProviderOutboundRequestMutationScope,
|
||||
ProviderOutboundRequestPolicy, ProviderOutboundRequestPolicyOutcome,
|
||||
ProviderOutboundRequestPolicyReason, ProviderOutboundRequestPolicyResult,
|
||||
PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use policy::{
|
||||
local_gemini_transport_unsupported_reason,
|
||||
local_gemini_transport_unsupported_reason_with_network,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
/// Stable request-level signals that provider-specific outbound policies may use.
|
||||
///
|
||||
/// The context deliberately carries client inputs rather than provider-derived
|
||||
/// identities. Policies remain responsible for deriving and applying their own
|
||||
/// wire representation at the terminal transport boundary.
|
||||
const CONTEXT_HASH_DOMAIN: &[u8] = b"aether-provider-outbound-context-v1";
|
||||
const CONTEXT_HASH_PREFIX: &str = "aether:provider-context:v1:";
|
||||
/// Maximum byte length of any value retained in a cross-stage provider context.
|
||||
///
|
||||
/// The limit is enforced before a context can reach Live persistence. Values
|
||||
/// above the limit are represented by a deterministic, field-scoped digest so
|
||||
/// retries keep the same policy identity without allowing unbounded client
|
||||
/// input into the registry.
|
||||
pub const PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderOutboundRequestContext {
|
||||
logical_turn_id: String,
|
||||
original_turn_id: Option<String>,
|
||||
original_client_session_id: Option<String>,
|
||||
original_prompt_cache_key: Option<String>,
|
||||
turn_started_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
impl ProviderOutboundRequestContext {
|
||||
pub fn new(logical_turn_id: impl Into<String>, turn_started_at_unix_ms: u64) -> Self {
|
||||
Self {
|
||||
logical_turn_id: canonical_required_value(logical_turn_id.into(), "logical_turn_id"),
|
||||
original_turn_id: None,
|
||||
original_client_session_id: None,
|
||||
original_prompt_cache_key: None,
|
||||
turn_started_at_unix_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_original_turn_id(mut self, original_turn_id: impl Into<String>) -> Self {
|
||||
self.original_turn_id = canonical_optional_value(original_turn_id.into(), "turn_id");
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_original_client_session_id(
|
||||
mut self,
|
||||
original_client_session_id: impl Into<String>,
|
||||
) -> Self {
|
||||
self.original_client_session_id =
|
||||
canonical_optional_value(original_client_session_id.into(), "client_session_id");
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_original_prompt_cache_key(
|
||||
mut self,
|
||||
original_prompt_cache_key: impl Into<String>,
|
||||
) -> Self {
|
||||
self.original_prompt_cache_key =
|
||||
canonical_optional_value(original_prompt_cache_key.into(), "prompt_cache_key");
|
||||
self
|
||||
}
|
||||
|
||||
pub fn logical_turn_id(&self) -> &str {
|
||||
self.logical_turn_id.as_str()
|
||||
}
|
||||
|
||||
pub fn original_turn_id(&self) -> Option<&str> {
|
||||
self.original_turn_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn original_client_session_id(&self) -> Option<&str> {
|
||||
self.original_client_session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn original_prompt_cache_key(&self) -> Option<&str> {
|
||||
self.original_prompt_cache_key.as_deref()
|
||||
}
|
||||
|
||||
pub fn turn_started_at_unix_ms(&self) -> u64 {
|
||||
self.turn_started_at_unix_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderOutboundRequestPolicy {
|
||||
CodexFingerprintConvergence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderOutboundRequestPolicyOutcome {
|
||||
Applied,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderOutboundRequestPolicyReason {
|
||||
Applied,
|
||||
ProviderTypeMismatch,
|
||||
AgentIdentityExcluded,
|
||||
UnsupportedApiFormat,
|
||||
CompactOperationExcluded,
|
||||
Disabled,
|
||||
RequestBodyNotObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderOutboundRequestMutationScope {
|
||||
Headers,
|
||||
Body,
|
||||
HeadersAndBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderOutboundRequestIdentityScope {
|
||||
PersistedFingerprint,
|
||||
AccountMember,
|
||||
Member,
|
||||
Account,
|
||||
Key,
|
||||
}
|
||||
|
||||
/// Low-sensitivity report for one selected provider-specific policy.
|
||||
///
|
||||
/// This type intentionally contains only categorical values. Derived identity
|
||||
/// values, client identifiers, and cache keys must never be added to it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderOutboundRequestPolicyResult {
|
||||
pub policy: ProviderOutboundRequestPolicy,
|
||||
pub outcome: ProviderOutboundRequestPolicyOutcome,
|
||||
pub reason: ProviderOutboundRequestPolicyReason,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mutation_scope: Option<ProviderOutboundRequestMutationScope>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_scope: Option<ProviderOutboundRequestIdentityScope>,
|
||||
}
|
||||
|
||||
impl ProviderOutboundRequestPolicyResult {
|
||||
pub(crate) fn applied(
|
||||
policy: ProviderOutboundRequestPolicy,
|
||||
mutation_scope: ProviderOutboundRequestMutationScope,
|
||||
identity_scope: ProviderOutboundRequestIdentityScope,
|
||||
) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
outcome: ProviderOutboundRequestPolicyOutcome::Applied,
|
||||
reason: ProviderOutboundRequestPolicyReason::Applied,
|
||||
mutation_scope: Some(mutation_scope),
|
||||
identity_scope: Some(identity_scope),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn skipped(
|
||||
policy: ProviderOutboundRequestPolicy,
|
||||
reason: ProviderOutboundRequestPolicyReason,
|
||||
) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
outcome: ProviderOutboundRequestPolicyOutcome::Skipped,
|
||||
reason,
|
||||
mutation_scope: None,
|
||||
identity_scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn was_applied(&self) -> bool {
|
||||
self.outcome == ProviderOutboundRequestPolicyOutcome::Applied
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the statically registered outbound policies for the final provider.
|
||||
///
|
||||
/// Provider selection has already completed at this boundary. A provider with
|
||||
/// no registered adapter is a strict no-op and produces no policy result.
|
||||
pub fn apply_provider_outbound_request_policies(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
context: &ProviderOutboundRequestContext,
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &mut Value,
|
||||
) -> Vec<ProviderOutboundRequestPolicyResult> {
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![
|
||||
crate::codex_fingerprint::apply_codex_fingerprint_convergence_policy(
|
||||
transport,
|
||||
provider_api_format,
|
||||
context,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn canonical_required_value(value: String, field: &str) -> String {
|
||||
let value = value.trim();
|
||||
// Bound the JSON-encoded representation, not just the source bytes. This
|
||||
// preserves ordinary Unicode while preventing quotes, backslashes, or
|
||||
// control characters from escaping beyond the aggregate Live record
|
||||
// budget.
|
||||
if value.len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES
|
||||
&& serde_json::to_string(value).is_ok_and(|encoded| {
|
||||
encoded.len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES.saturating_add(2)
|
||||
})
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
digest_context_value(field, value)
|
||||
}
|
||||
|
||||
fn canonical_optional_value(value: String, field: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| canonical_required_value(value.to_string(), field))
|
||||
}
|
||||
|
||||
fn digest_context_value(field: &str, value: &str) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(CONTEXT_HASH_DOMAIN);
|
||||
digest.update([0]);
|
||||
digest.update(field.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update((value.len() as u64).to_be_bytes());
|
||||
digest.update(value.as_bytes());
|
||||
format!("{CONTEXT_HASH_PREFIX}{field}:{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider,
|
||||
};
|
||||
|
||||
fn sample_transport(provider_type: &str) -> 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: Some(json!({
|
||||
"codex": {"fingerprint_convergence_enabled": true}
|
||||
})),
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:responses".to_string(),
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: "https://example.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
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 dispatcher_leaves_unregistered_provider_requests_unchanged() {
|
||||
let transport = sample_transport("openai");
|
||||
let context = ProviderOutboundRequestContext::new("logical-turn", 1_700_000_000_123);
|
||||
let original_headers = BTreeMap::from([("x-custom".to_string(), "preserve".to_string())]);
|
||||
let original_body = json!({"model": "gpt-5.4", "custom": true});
|
||||
let mut headers = original_headers.clone();
|
||||
let mut body = original_body.clone();
|
||||
|
||||
let results = apply_provider_outbound_request_policies(
|
||||
&transport,
|
||||
"openai:responses",
|
||||
&context,
|
||||
&mut headers,
|
||||
&mut body,
|
||||
);
|
||||
|
||||
assert!(results.is_empty());
|
||||
assert_eq!(headers, original_headers);
|
||||
assert_eq!(body, original_body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_serialization_is_categorical_and_snake_case() {
|
||||
let result = ProviderOutboundRequestPolicyResult::applied(
|
||||
ProviderOutboundRequestPolicy::CodexFingerprintConvergence,
|
||||
ProviderOutboundRequestMutationScope::HeadersAndBody,
|
||||
ProviderOutboundRequestIdentityScope::AccountMember,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(result).expect("serialize policy result"),
|
||||
json!({
|
||||
"policy": "codex_fingerprint_convergence",
|
||||
"outcome": "applied",
|
||||
"reason": "applied",
|
||||
"mutation_scope": "headers_and_body",
|
||||
"identity_scope": "account_member"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_values_are_bounded_and_deterministic() {
|
||||
let oversized = "x".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1);
|
||||
let context = ProviderOutboundRequestContext::new(oversized.clone(), 1)
|
||||
.with_original_turn_id(oversized.clone())
|
||||
.with_original_client_session_id(oversized.clone())
|
||||
.with_original_prompt_cache_key(oversized);
|
||||
let same_context = ProviderOutboundRequestContext::new(
|
||||
"x".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1),
|
||||
1,
|
||||
)
|
||||
.with_original_turn_id("x".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1))
|
||||
.with_original_client_session_id("x".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1))
|
||||
.with_original_prompt_cache_key("x".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1));
|
||||
|
||||
assert_eq!(context, same_context);
|
||||
assert!(context.logical_turn_id().len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES);
|
||||
assert!(context
|
||||
.original_turn_id()
|
||||
.is_some_and(|value| value.len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES));
|
||||
assert!(context
|
||||
.original_client_session_id()
|
||||
.is_some_and(|value| value.len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES));
|
||||
assert!(context
|
||||
.original_prompt_cache_key()
|
||||
.is_some_and(|value| value.len() <= PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES));
|
||||
assert!(context.logical_turn_id().starts_with(CONTEXT_HASH_PREFIX));
|
||||
assert_ne!(
|
||||
context.logical_turn_id(),
|
||||
ProviderOutboundRequestContext::new(
|
||||
"y".repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES + 1),
|
||||
1,
|
||||
)
|
||||
.logical_turn_id()
|
||||
);
|
||||
|
||||
let unicode = ProviderOutboundRequestContext::new("turn-你好", 1);
|
||||
assert_eq!(unicode.logical_turn_id(), "turn-你好");
|
||||
let escaped = ProviderOutboundRequestContext::new(
|
||||
r#"""#.repeat(PROVIDER_OUTBOUND_CONTEXT_MAX_VALUE_BYTES),
|
||||
1,
|
||||
);
|
||||
assert!(escaped.logical_turn_id().starts_with(CONTEXT_HASH_PREFIX));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user