mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑
- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token - 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口 - billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费 - usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性 - 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出 - executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义 - provider-transport auth 层新增 complete passthrough headers 构建逻辑 - 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
This commit is contained in:
@@ -7,7 +7,9 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::build_openai_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_complete_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
};
|
||||
use crate::ai_pipeline::transport::claude_code::build_claude_code_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::kiro::{
|
||||
build_kiro_provider_headers, KiroProviderHeadersInput, KIRO_ENVELOPE_NAME,
|
||||
@@ -198,13 +200,13 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
||||
transport.key.fingerprint.as_ref(),
|
||||
)
|
||||
} else if is_vertex {
|
||||
crate::ai_pipeline::transport::build_passthrough_headers(
|
||||
build_complete_passthrough_headers(
|
||||
&parts.headers,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
auth_header.as_deref().unwrap_or_default(),
|
||||
auth_value.as_deref().unwrap_or_default(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use tracing::warn;
|
||||
use crate::ai_pipeline::planner::common::force_upstream_streaming_for_provider;
|
||||
use crate::ai_pipeline::planner::standard::apply_codex_openai_cli_special_headers;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||
@@ -245,13 +245,23 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
}
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::build_cross_format_openai_cli_request_body;
|
||||
use super::{build_cross_format_openai_cli_request_body, build_local_openai_cli_request_body};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
@@ -26,6 +35,52 @@ fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_cli_wrapper_preserves_body_order_after_edits() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"text": {"format": {"type": "text"}},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto"
|
||||
}"#,
|
||||
)
|
||||
.expect("request body should parse");
|
||||
|
||||
let provider_request_body = build_local_openai_cli_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
true,
|
||||
"codex",
|
||||
"openai:cli",
|
||||
None,
|
||||
Some("key-123"),
|
||||
)
|
||||
.expect("local openai cli body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_metadata_for_codex_openai_cli_requests() {
|
||||
let body_json = json!({
|
||||
|
||||
+18
-8
@@ -16,7 +16,7 @@ use crate::ai_pipeline::planner::standard::{
|
||||
build_cross_format_openai_chat_upstream_url,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||
@@ -182,13 +182,23 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
+3
-2
@@ -9,7 +9,8 @@ use crate::ai_pipeline::planner::standard::{
|
||||
build_local_openai_chat_upstream_url,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth,
|
||||
build_complete_passthrough_headers_with_auth, ensure_upstream_auth_header,
|
||||
resolve_local_openai_chat_auth,
|
||||
};
|
||||
use crate::ai_pipeline::transport::policy::supports_local_openai_chat_transport;
|
||||
use crate::ai_pipeline::transport::{
|
||||
@@ -140,7 +141,7 @@ pub(super) async fn build_same_format_local_openai_chat_decision_payload_for_can
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
let mut provider_request_headers = build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
|
||||
+30
-10
@@ -22,6 +22,7 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
};
|
||||
use crate::ai_pipeline::transport::apply_local_header_rules;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
|
||||
resolve_local_standard_auth,
|
||||
};
|
||||
@@ -354,16 +355,35 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default(),
|
||||
Some("application/json"),
|
||||
);
|
||||
let extra_headers = antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default();
|
||||
let mut provider_request_headers = if same_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
+298
-13
@@ -6,6 +6,7 @@ use super::super::{
|
||||
};
|
||||
use crate::ai_pipeline::provider_adaptation_requires_eventstream_accept;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||
@@ -132,13 +133,31 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
||||
};
|
||||
|
||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
if provider_api_format == client_api_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
payload.provider_request_headers.clone()
|
||||
};
|
||||
@@ -281,6 +300,11 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
||||
} else {
|
||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
}
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = ExecutionPlan {
|
||||
request_id,
|
||||
candidate_id: payload.candidate_id.clone(),
|
||||
@@ -306,15 +330,276 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
||||
timeouts: payload.timeouts.clone(),
|
||||
};
|
||||
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&plan.headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
|
||||
Ok(Some(LocalStreamPlanAndReport {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_stream_plan_from_decision, build_openai_cli_stream_plan_from_decision,
|
||||
};
|
||||
use crate::GatewayControlSyncDecisionResponse;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_cli_payload() -> GatewayControlSyncDecisionResponse {
|
||||
GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_cli_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:cli".to_string()),
|
||||
client_api_format: Some("openai:cli".to_string()),
|
||||
provider_contract: Some("openai:cli".to_string()),
|
||||
client_contract: Some("openai:cli".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_cli_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_cli_stream_plan_preserves_provider_request_body_order_in_plan_and_report() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_cli_payload();
|
||||
|
||||
let built = build_openai_cli_stream_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
let report_context = built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.expect("report context should contain provider request body");
|
||||
assert_eq!(object_keys(report_context), object_keys(plan_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_stream_456".to_string()),
|
||||
candidate_id: Some("cand_stream_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: Some("prov_stream_456".to_string()),
|
||||
endpoint_id: Some("ep_stream_456".to_string()),
|
||||
key_id: Some("key_stream_456".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":true})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_stream_789".to_string()),
|
||||
candidate_id: Some("cand_stream_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_id: Some("prov_stream_789".to_string()),
|
||||
endpoint_id: Some("ep_stream_789".to_string()),
|
||||
key_id: Some("key_stream_789".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":true}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+290
-13
@@ -5,6 +5,7 @@ use super::super::{
|
||||
LocalSyncPlanAndReport,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||
@@ -132,13 +133,31 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
||||
};
|
||||
|
||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
if provider_api_format == client_api_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
payload.provider_request_headers.clone()
|
||||
};
|
||||
@@ -275,6 +294,11 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
|
||||
if payload.upstream_is_stream && !provider_request_headers.contains_key("accept") {
|
||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
}
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = ExecutionPlan {
|
||||
request_id,
|
||||
candidate_id: payload.candidate_id.clone(),
|
||||
@@ -300,15 +324,268 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
|
||||
timeouts: payload.timeouts.clone(),
|
||||
};
|
||||
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&plan.headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
|
||||
Ok(Some(LocalSyncPlanAndReport {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_sync_plan_from_decision, build_openai_cli_sync_plan_from_decision,
|
||||
};
|
||||
use crate::GatewayControlSyncDecisionResponse;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_cli_payload() -> GatewayControlSyncDecisionResponse {
|
||||
GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_cli_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:cli".to_string()),
|
||||
client_api_format: Some("openai:cli".to_string()),
|
||||
provider_contract: Some("openai:cli".to_string()),
|
||||
client_contract: Some("openai:cli".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_cli_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_cli_sync_plan_preserves_provider_request_body_order_in_plan_and_report() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_cli_payload();
|
||||
|
||||
let built = build_openai_cli_sync_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
let report_context = built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.expect("report context should contain provider request body");
|
||||
assert_eq!(object_keys(report_context), object_keys(plan_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_456".to_string()),
|
||||
candidate_id: Some("cand_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: Some("prov_456".to_string()),
|
||||
endpoint_id: Some("ep_456".to_string()),
|
||||
key_id: Some("key_456".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":false})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_789".to_string()),
|
||||
candidate_id: Some("cand_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_id: Some("prov_789".to_string()),
|
||||
endpoint_id: Some("ep_789".to_string()),
|
||||
key_id: Some("key_789".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":false}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user