mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge pull request #380 from chopratejas/realign-E4-openai-prompt-cache-key
fix: PR-E4 OpenAI prompt_cache_key auto-injection (Phase E)
This commit is contained in:
commit
2fc73d0f73
8 changed files with 1340 additions and 11 deletions
|
|
@ -63,7 +63,8 @@ prometheus = { version = "0.13", default-features = false }
|
|||
# tools, early messages) for cache-bust drift detection. Already in
|
||||
# the dev-dependencies (and pulled transitively by `aws-sigv4` via
|
||||
# `aws-smithy-runtime-api`); promoted here to a direct, normal-build
|
||||
# dependency so the drift detector compiles outside `cfg(test)`.
|
||||
# dependency so the drift detector compiles outside `cfg(test)`. Also
|
||||
# used by PR-E4 for `prompt_cache_key` derivation.
|
||||
sha2 = "0.10"
|
||||
# PR-E6: bounded session-scoped cache of structural hashes. The
|
||||
# detector evicts the oldest session at 1000 entries — we never want
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
//! groups every cache-stabilization mechanism behind one module so
|
||||
//! operators searching for "what does Headroom do to keep prompt
|
||||
//! caches warm" land in one place. Phase E PRs in this module sit
|
||||
//! *next to* the request path — never on it. They observe inbound
|
||||
//! bodies and emit structured logs so customers can see why their
|
||||
//! prompt-cache hit rate is degrading. Nothing in here mutates
|
||||
//! request bytes; the cache-safety invariant from Phase A still holds.
|
||||
//! *next to* the request path — either as observers
|
||||
//! (volatile_detector, drift_detector) or as PAYG-gated mutators
|
||||
//! (openai_cache_key, anthropic cache_control). The Phase A
|
||||
//! "passthrough is sacred" invariant still holds: mutators MUST
|
||||
//! gate on `AuthMode::Payg` at their call sites before invoking
|
||||
//! any function that mutates the body. Observers never mutate.
|
||||
//!
|
||||
//! Currently shipped:
|
||||
//!
|
||||
|
|
@ -28,14 +30,19 @@
|
|||
//! learning Anthropic's marker API. **Mutates request bytes**;
|
||||
//! gated on auth_mode == PAYG and the absence of any pre-existing
|
||||
//! marker.
|
||||
//! - [`openai_cache_key`] — PR-E4: on PAYG OpenAI requests where the
|
||||
//! customer has not set `prompt_cache_key`, derive a stable key from
|
||||
//! `(model, system, tools)` and inject it so the upstream pins
|
||||
//! cache lookup to a tenant-stable identity. **Mutates the body**
|
||||
//! (only on PAYG) — see its docs for the gating contract.
|
||||
//!
|
||||
//! Future PRs (E1 — tool-array sort, E2 — JSON Schema key sort, E3 —
|
||||
//! `cache_control` auto-placement, E4 — `prompt_cache_key` injection)
|
||||
//! hang sibling submodules off this same `mod.rs`. Conflict
|
||||
//! resolution between parallel Phase E PRs is intentionally trivial:
|
||||
//! each detector lives in its own file, the only shared surface is
|
||||
//! this `mod.rs`'s `pub mod` list.
|
||||
//! `cache_control` auto-placement) hang sibling submodules off this
|
||||
//! same `mod.rs`. Conflict resolution between parallel Phase E PRs
|
||||
//! is intentionally trivial: each detector lives in its own file,
|
||||
//! the only shared surface is this `mod.rs`'s `pub mod` list.
|
||||
|
||||
pub mod anthropic_cache_control;
|
||||
pub mod drift_detector;
|
||||
pub mod openai_cache_key;
|
||||
pub mod volatile_detector;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,600 @@
|
|||
//! PR-E4: OpenAI `prompt_cache_key` auto-injection.
|
||||
//!
|
||||
//! OpenAI's prefix caching is automatic since late 2024 — the API
|
||||
//! caches prefix tokens server-side without client opt-in. The
|
||||
//! `prompt_cache_key` field, however, is the field a client passes
|
||||
//! to **pin cache lookup** to a stable identity. Without it,
|
||||
//! OpenAI falls back to org-wide cache lookups that may collide
|
||||
//! with other tenants. Most clients don't set the field because
|
||||
//! they don't know it exists. This module derives a deterministic
|
||||
//! key from the request's *structural* prefix (model + system +
|
||||
//! tools) and injects it on PAYG requests where the customer has
|
||||
//! not already provided one.
|
||||
//!
|
||||
//! # Universal safety contract
|
||||
//!
|
||||
//! This module mutates request bytes — that is the whole point.
|
||||
//! Mutating bytes for non-PAYG auth modes risks looking like
|
||||
//! cache-evasion to the upstream and would void OAuth scope; the
|
||||
//! caller MUST gate on `AuthMode::Payg` before calling
|
||||
//! [`inject_prompt_cache_key`]. The function itself does NOT
|
||||
//! re-classify auth mode: that is the caller's responsibility,
|
||||
//! consistent with PR-E3's `cache_control` placement which gates
|
||||
//! the same way at the dispatch site.
|
||||
//!
|
||||
//! # Skip rules (this module enforces these directly)
|
||||
//!
|
||||
//! - **Already present**: if `body.prompt_cache_key` exists at the
|
||||
//! top level and is a non-empty string, return
|
||||
//! [`InjectOutcome::Skipped`] with reason
|
||||
//! [`SkipReason::KeyPresent`]. The customer's value wins.
|
||||
//! - **Idempotency**: re-running on a body that already has *our*
|
||||
//! injected key returns [`SkipReason::KeyPresent`] (we don't
|
||||
//! distinguish ours from theirs — both mean "leave alone").
|
||||
//!
|
||||
//! # Key derivation
|
||||
//!
|
||||
//! Inputs to the hash, in order:
|
||||
//!
|
||||
//! 1. `body.model` (string) — different model = different cache
|
||||
//! universe.
|
||||
//! 2. SHA-256 of canonical-JSON bytes of the system content.
|
||||
//! For Chat Completions, this is the first message with
|
||||
//! `role == "system"`. For Responses, this is the
|
||||
//! `instructions` field if present, else the first
|
||||
//! `role == "system"` item.
|
||||
//! 3. SHA-256 of canonical-JSON bytes of `body.tools`.
|
||||
//!
|
||||
//! User and assistant messages are **deliberately excluded** —
|
||||
//! including them would defeat the purpose by producing a
|
||||
//! different key on every turn.
|
||||
//!
|
||||
//! Combined: `key = hex(sha256(model || system_hash ||
|
||||
//! tools_hash))[..32]` — 128 bits of collision resistance, 32
|
||||
//! hex chars on the wire, compact in logs.
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Length of the injected key in hex characters. 32 hex chars =
|
||||
/// 128 bits, plenty of collision resistance for per-tenant cache
|
||||
/// pinning, while staying compact on the wire and in logs.
|
||||
pub const KEY_HEX_LEN: usize = 32;
|
||||
|
||||
/// First-N chars of the key surfaced in `tracing` events. **Never**
|
||||
/// log the full key — it is identifying material for cache pinning
|
||||
/// and operators do not need the suffix to debug.
|
||||
pub const KEY_PREFIX_LOG_LEN: usize = 8;
|
||||
|
||||
/// Which OpenAI request shape the body conforms to. The shape
|
||||
/// affects only where the system content lives:
|
||||
///
|
||||
/// - [`OpenAiShape::ChatCompletions`]: first `messages[*]` with
|
||||
/// `role == "system"`.
|
||||
/// - [`OpenAiShape::Responses`]: top-level `instructions` field,
|
||||
/// else first `input[*]` (or `messages[*]`) with
|
||||
/// `role == "system"`.
|
||||
///
|
||||
/// `tools` lives at the top level in both shapes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpenAiShape {
|
||||
/// `POST /v1/chat/completions`.
|
||||
ChatCompletions,
|
||||
/// `POST /v1/responses`.
|
||||
Responses,
|
||||
}
|
||||
|
||||
/// Why the injector skipped a body.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkipReason {
|
||||
/// The body is not a JSON object (the dispatcher should have
|
||||
/// gated this earlier; we surface it as a skip rather than
|
||||
/// panicking so the proxy stays a transparent forwarder for
|
||||
/// pathological inputs).
|
||||
NotAnObject,
|
||||
/// `body.prompt_cache_key` is already a non-empty string.
|
||||
/// Customer-set values win.
|
||||
KeyPresent,
|
||||
}
|
||||
|
||||
impl SkipReason {
|
||||
/// Stable string for structured-log fields. Dashboards filter
|
||||
/// on this; do not change without a deprecation note.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SkipReason::NotAnObject => "not_an_object",
|
||||
SkipReason::KeyPresent => "key_present",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an injection attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InjectOutcome {
|
||||
/// We added a `prompt_cache_key` to the body. The first
|
||||
/// [`KEY_PREFIX_LOG_LEN`] hex chars of the key, suitable for
|
||||
/// logs. The full key is in the body — never log it.
|
||||
Applied { key_prefix: String },
|
||||
/// We left the body unchanged. See [`SkipReason`] for why.
|
||||
Skipped { reason: SkipReason },
|
||||
}
|
||||
|
||||
/// Top-level field check.
|
||||
///
|
||||
/// Returns `true` if `body.prompt_cache_key` is a non-empty
|
||||
/// string. Empty strings count as "absent" because some SDKs
|
||||
/// default the field to `""` when the user did not set it.
|
||||
pub fn has_prompt_cache_key(body: &Value) -> bool {
|
||||
body.get("prompt_cache_key")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Inject a `prompt_cache_key` into `body` if appropriate.
|
||||
///
|
||||
/// **Caller MUST gate on `AuthMode::Payg`.** This function does not
|
||||
/// re-classify auth mode — same contract as PR-E3
|
||||
/// `cache_control` placement.
|
||||
///
|
||||
/// Returns:
|
||||
/// - [`InjectOutcome::Applied`] when the key is set;
|
||||
/// `body["prompt_cache_key"]` is now a 32-hex-char string.
|
||||
/// - [`InjectOutcome::Skipped`] when the body is not a JSON
|
||||
/// object, or already has a non-empty `prompt_cache_key`.
|
||||
///
|
||||
/// This function is **deterministic**: the same `(model, system,
|
||||
/// tools)` triple always produces the same key, so re-running on a
|
||||
/// body whose injected key was stripped yields the same key
|
||||
/// (idempotency property the test suite pins).
|
||||
pub fn inject_prompt_cache_key(body: &mut Value, shape: OpenAiShape) -> InjectOutcome {
|
||||
// Guardrail: top-level must be an object. Anything else (array,
|
||||
// string, null) is malformed for our hook point; the dispatcher
|
||||
// already gates on `messages` / `input` being arrays, so this
|
||||
// path is essentially unreachable but we surface it explicitly
|
||||
// rather than panicking.
|
||||
if !body.is_object() {
|
||||
return InjectOutcome::Skipped {
|
||||
reason: SkipReason::NotAnObject,
|
||||
};
|
||||
}
|
||||
|
||||
if has_prompt_cache_key(body) {
|
||||
return InjectOutcome::Skipped {
|
||||
reason: SkipReason::KeyPresent,
|
||||
};
|
||||
}
|
||||
|
||||
let key = derive_key(body, shape);
|
||||
let key_prefix = key[..KEY_PREFIX_LOG_LEN.min(key.len())].to_string();
|
||||
|
||||
// Safe: we just verified `body.is_object()` above.
|
||||
if let Some(map) = body.as_object_mut() {
|
||||
map.insert("prompt_cache_key".to_string(), Value::String(key));
|
||||
}
|
||||
|
||||
InjectOutcome::Applied { key_prefix }
|
||||
}
|
||||
|
||||
/// Derive the cache key from the structural prefix of the body.
|
||||
///
|
||||
/// Hash inputs (concatenated, length-prefixed to avoid ambiguity
|
||||
/// between e.g. `model="ab", system="c"` vs `model="a", system="bc"`):
|
||||
///
|
||||
/// 1. ASCII bytes of the model field (empty if missing).
|
||||
/// 2. SHA-256 of canonical-JSON bytes of the system content.
|
||||
/// 3. SHA-256 of canonical-JSON bytes of the tools field.
|
||||
///
|
||||
/// Length-prefixing is critical: without it, two different
|
||||
/// (model, system) splits could collide. We use a single `0x00`
|
||||
/// byte separator instead of an explicit u32 length because
|
||||
/// `0x00` cannot appear in a UTF-8 model name or a hex digest;
|
||||
/// the separator unambiguously delimits the three inputs.
|
||||
fn derive_key(body: &Value, shape: OpenAiShape) -> String {
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let system_value = extract_system(body, shape);
|
||||
let system_hash = canonical_sha256(&system_value);
|
||||
|
||||
let tools_value = body.get("tools").cloned().unwrap_or(Value::Null);
|
||||
let tools_hash = canonical_sha256(&tools_value);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(model.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
hasher.update(system_hash.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
hasher.update(tools_hash.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
|
||||
// 16 bytes = 32 hex chars. We hex-encode by hand to avoid
|
||||
// pulling in `hex` for one call site.
|
||||
let mut out = String::with_capacity(KEY_HEX_LEN);
|
||||
for byte in digest.iter().take(KEY_HEX_LEN / 2) {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Locate the system content for the given shape, returning the
|
||||
/// JSON value (possibly `Null`) we will hash. We hash the JSON
|
||||
/// value (not its serialized bytes alone) so that
|
||||
/// content-block-array systems and string systems with the same
|
||||
/// concatenated text produce *different* keys — which is the
|
||||
/// correct behaviour for cache pinning.
|
||||
fn extract_system(body: &Value, shape: OpenAiShape) -> Value {
|
||||
match shape {
|
||||
OpenAiShape::ChatCompletions => first_system_message_content(body, "messages"),
|
||||
OpenAiShape::Responses => {
|
||||
// Responses canonical: top-level `instructions`.
|
||||
// Legacy alias: a system message in `input` (or `messages`).
|
||||
if let Some(instructions) = body.get("instructions") {
|
||||
return instructions.clone();
|
||||
}
|
||||
if let Some(v) = body.get("input") {
|
||||
if let Some(content) = first_system_in_array(v) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
first_system_message_content(body, "messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First `messages[*]` (under the given key) with `role == "system"` —
|
||||
/// returns the message's `content` field, or `Null` if none found.
|
||||
fn first_system_message_content(body: &Value, key: &str) -> Value {
|
||||
body.get(key)
|
||||
.and_then(first_system_in_array)
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn first_system_in_array(arr: &Value) -> Option<Value> {
|
||||
let items = arr.as_array()?;
|
||||
for item in items {
|
||||
if item.get("role").and_then(Value::as_str) == Some("system") {
|
||||
return item.get("content").cloned().or(Some(Value::Null));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// SHA-256 of canonical-JSON bytes of `v`, hex-encoded.
|
||||
///
|
||||
/// We use `serde_json::to_vec` rather than `to_string` to avoid an
|
||||
/// unnecessary UTF-8 validation pass; `serde_json` always emits
|
||||
/// valid UTF-8 anyway. Object keys are emitted in insertion order
|
||||
/// (workspace `serde_json` is built with `preserve_order`), which
|
||||
/// is acceptable here because the **same** request body always
|
||||
/// hashes the **same** way — and that is the only invariant we
|
||||
/// require for stable cache pinning.
|
||||
fn canonical_sha256(v: &Value) -> String {
|
||||
let bytes = serde_json::to_vec(v).unwrap_or_default();
|
||||
let digest = Sha256::digest(&bytes);
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest.iter() {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn is_hex(s: &str) -> bool {
|
||||
s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn injected_key(body: &Value) -> String {
|
||||
body.get("prompt_cache_key")
|
||||
.and_then(Value::as_str)
|
||||
.expect("prompt_cache_key must be a string")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_key_when_payg_and_absent() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
});
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::ChatCompletions);
|
||||
match outcome {
|
||||
InjectOutcome::Applied { key_prefix } => {
|
||||
assert_eq!(key_prefix.len(), KEY_PREFIX_LOG_LEN);
|
||||
assert!(is_hex(&key_prefix));
|
||||
}
|
||||
other => panic!("expected Applied, got {other:?}"),
|
||||
}
|
||||
let key = injected_key(&body);
|
||||
assert_eq!(key.len(), KEY_HEX_LEN);
|
||||
assert!(is_hex(&key), "key must be hex: {key}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_key_when_payg_and_absent_responses() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-4o",
|
||||
"instructions": "You are a helpful assistant.",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
});
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::Responses);
|
||||
assert!(matches!(outcome, InjectOutcome::Applied { .. }));
|
||||
let key = injected_key(&body);
|
||||
assert_eq!(key.len(), KEY_HEX_LEN);
|
||||
assert!(is_hex(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_key_already_set() {
|
||||
let original = json!({
|
||||
"model": "gpt-4o",
|
||||
"prompt_cache_key": "user-pinned",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
});
|
||||
let mut body = original.clone();
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::ChatCompletions);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InjectOutcome::Skipped {
|
||||
reason: SkipReason::KeyPresent,
|
||||
}
|
||||
);
|
||||
assert_eq!(body, original, "body must be unchanged when key is present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_key_already_set_responses() {
|
||||
let original = json!({
|
||||
"model": "gpt-4o",
|
||||
"prompt_cache_key": "user-pinned-2",
|
||||
"instructions": "Be concise.",
|
||||
"input": [{"role": "user", "content": "Hello"}],
|
||||
});
|
||||
let mut body = original.clone();
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::Responses);
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
InjectOutcome::Skipped {
|
||||
reason: SkipReason::KeyPresent
|
||||
}
|
||||
));
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_body_is_not_an_object() {
|
||||
let mut body = json!(["not", "an", "object"]);
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::ChatCompletions);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InjectOutcome::Skipped {
|
||||
reason: SkipReason::NotAnObject,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_key_treated_as_absent() {
|
||||
// Some SDKs default the field to "" when the user did not
|
||||
// set it. We treat that as absent and inject anyway.
|
||||
let mut body = json!({
|
||||
"model": "gpt-4o",
|
||||
"prompt_cache_key": "",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
],
|
||||
});
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::ChatCompletions);
|
||||
assert!(matches!(outcome, InjectOutcome::Applied { .. }));
|
||||
let key = injected_key(&body);
|
||||
assert_eq!(key.len(), KEY_HEX_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_same_inputs_same_key() {
|
||||
// Re-running on a body whose injected key was stripped
|
||||
// (e.g. customer-side scrub) must yield the same key.
|
||||
let template = || {
|
||||
json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "first turn"}
|
||||
],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "search"}}
|
||||
]
|
||||
})
|
||||
};
|
||||
let mut body1 = template();
|
||||
let _ = inject_prompt_cache_key(&mut body1, OpenAiShape::ChatCompletions);
|
||||
let key1 = injected_key(&body1);
|
||||
|
||||
let mut body2 = template();
|
||||
let _ = inject_prompt_cache_key(&mut body2, OpenAiShape::ChatCompletions);
|
||||
let key2 = injected_key(&body2);
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_model_yields_different_key() {
|
||||
let mut a = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "S"}, {"role": "user", "content": "u"}],
|
||||
});
|
||||
let mut b = json!({
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "system", "content": "S"}, {"role": "user", "content": "u"}],
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut a, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut b, OpenAiShape::ChatCompletions);
|
||||
assert_ne!(injected_key(&a), injected_key(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_system_yields_different_key() {
|
||||
let mut a = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "system A"}, {"role": "user", "content": "u"}],
|
||||
});
|
||||
let mut b = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "system B"}, {"role": "user", "content": "u"}],
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut a, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut b, OpenAiShape::ChatCompletions);
|
||||
assert_ne!(injected_key(&a), injected_key(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_tools_yields_different_key() {
|
||||
let mut a = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "S"}, {"role": "user", "content": "u"}],
|
||||
"tools": [{"type": "function", "function": {"name": "search"}}],
|
||||
});
|
||||
let mut b = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "S"}, {"role": "user", "content": "u"}],
|
||||
"tools": [{"type": "function", "function": {"name": "lookup"}}],
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut a, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut b, OpenAiShape::ChatCompletions);
|
||||
assert_ne!(injected_key(&a), injected_key(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_user_messages_different_system_yields_different_key() {
|
||||
// Confirms user content is NOT in the hash by holding user
|
||||
// content fixed and varying only the system. If user
|
||||
// content were hashed, this could still differ — but the
|
||||
// companion test below pins the inverse direction.
|
||||
let user = json!({"role": "user", "content": "what is 2+2?"});
|
||||
let mut a = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "concise"}, user.clone()],
|
||||
});
|
||||
let mut b = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "verbose"}, user],
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut a, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut b, OpenAiShape::ChatCompletions);
|
||||
assert_ne!(injected_key(&a), injected_key(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_system_different_user_messages_yields_same_key() {
|
||||
// The crucial property: user/assistant turns vary across
|
||||
// requests, but the cache key must STAY CONSTANT so OpenAI
|
||||
// can pin the cache lookup to the same identity.
|
||||
let mut a = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are concise."},
|
||||
{"role": "user", "content": "first question"}
|
||||
],
|
||||
});
|
||||
let mut b = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are concise."},
|
||||
{"role": "user", "content": "second question, much longer than the first"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "third question"}
|
||||
],
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut a, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut b, OpenAiShape::ChatCompletions);
|
||||
assert_eq!(
|
||||
injected_key(&a),
|
||||
injected_key(&b),
|
||||
"user/assistant turn variation must not change the cache key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_length_is_exactly_32_hex_chars() {
|
||||
// Property test: across many input shapes the key is
|
||||
// always exactly 32 hex chars.
|
||||
let inputs = [
|
||||
json!({"model": "gpt-4o", "messages": []}),
|
||||
json!({"model": "gpt-4o-2024-08-06", "messages": [{"role": "system", "content": "abc"}]}),
|
||||
json!({"model": "o1-mini", "messages": [{"role": "system", "content": ["array", "content"]}]}),
|
||||
json!({"model": "", "messages": [{"role": "user", "content": "no system"}]}),
|
||||
json!({"model": "gpt-4o", "instructions": "responses style"}),
|
||||
];
|
||||
for (i, base) in inputs.iter().enumerate() {
|
||||
let mut body = base.clone();
|
||||
let outcome = inject_prompt_cache_key(&mut body, OpenAiShape::ChatCompletions);
|
||||
assert!(
|
||||
matches!(outcome, InjectOutcome::Applied { .. }),
|
||||
"input {i}: expected Applied"
|
||||
);
|
||||
let key = injected_key(&body);
|
||||
assert_eq!(
|
||||
key.len(),
|
||||
KEY_HEX_LEN,
|
||||
"input {i}: key {key:?} must be {KEY_HEX_LEN} chars"
|
||||
);
|
||||
assert!(is_hex(&key), "input {i}: key {key:?} must be hex");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_instructions_field_distinguishes_from_chat_system() {
|
||||
// Same model, same logical "system", different shape:
|
||||
// the keys may legally differ (different containers hash
|
||||
// differently). What we DO assert is that running the
|
||||
// same shape on the same body is stable (covered by
|
||||
// idempotency test) and that running each shape produces
|
||||
// a valid key.
|
||||
let mut body_chat = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "system", "content": "S"}],
|
||||
});
|
||||
let mut body_resp = json!({
|
||||
"model": "gpt-4o",
|
||||
"instructions": "S",
|
||||
});
|
||||
let _ = inject_prompt_cache_key(&mut body_chat, OpenAiShape::ChatCompletions);
|
||||
let _ = inject_prompt_cache_key(&mut body_resp, OpenAiShape::Responses);
|
||||
assert_eq!(injected_key(&body_chat).len(), KEY_HEX_LEN);
|
||||
assert_eq!(injected_key(&body_resp).len(), KEY_HEX_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_prompt_cache_key_works() {
|
||||
assert!(!has_prompt_cache_key(&json!({})));
|
||||
assert!(!has_prompt_cache_key(&json!({"prompt_cache_key": ""})));
|
||||
assert!(!has_prompt_cache_key(&json!({"prompt_cache_key": null})));
|
||||
assert!(has_prompt_cache_key(&json!({"prompt_cache_key": "x"})));
|
||||
assert!(has_prompt_cache_key(
|
||||
&json!({"prompt_cache_key": "user-pinned"})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_reason_string_is_stable() {
|
||||
// Dashboards filter on these strings. Don't change without
|
||||
// a deprecation note.
|
||||
assert_eq!(SkipReason::NotAnObject.as_str(), "not_an_object");
|
||||
assert_eq!(SkipReason::KeyPresent.as_str(), "key_present");
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ use crate::websocket::ws_handler;
|
|||
// site self-documenting. `AuthMode` is re-exported under the same
|
||||
// path for downstream handlers that read the value back out of
|
||||
// `req.extensions()` (Phase F PR-F2/F3/F4).
|
||||
use headroom_core::auth_mode::classify as classify_auth_mode;
|
||||
use headroom_core::auth_mode::{classify as classify_auth_mode, AuthMode};
|
||||
|
||||
/// Shared state passed to every handler.
|
||||
///
|
||||
|
|
@ -731,6 +731,40 @@ pub(crate) async fn forward_http(
|
|||
}
|
||||
};
|
||||
|
||||
// PR-E4: OpenAI `prompt_cache_key` auto-injection.
|
||||
//
|
||||
// Universal safety contract: only mutate when the caller
|
||||
// is on `AuthMode::Payg`. OAuth/Subscription bytes flow
|
||||
// through byte-equal — those clients cannot afford
|
||||
// synthesised cache keys (OAuth scopes pin to
|
||||
// `(account, model, session)` and subscription clients
|
||||
// are programmatically fingerprinted by the upstream).
|
||||
//
|
||||
// The injector also self-skips when the customer has
|
||||
// already set a non-empty `prompt_cache_key`. Every skip
|
||||
// path emits a structured `e4_skipped` event so cache-hit
|
||||
// dashboards can attribute miss rates to gating reasons
|
||||
// rather than guessing.
|
||||
let body_to_send = match endpoint {
|
||||
compression::CompressibleEndpoint::OpenAiChatCompletions
|
||||
| compression::CompressibleEndpoint::OpenAiResponses => {
|
||||
let shape = match endpoint {
|
||||
compression::CompressibleEndpoint::OpenAiResponses => {
|
||||
cache_stabilization::openai_cache_key::OpenAiShape::Responses
|
||||
}
|
||||
_ => cache_stabilization::openai_cache_key::OpenAiShape::ChatCompletions,
|
||||
};
|
||||
maybe_inject_openai_prompt_cache_key(
|
||||
body_to_send,
|
||||
shape,
|
||||
auth_mode,
|
||||
&request_id,
|
||||
&path_for_log,
|
||||
)
|
||||
}
|
||||
compression::CompressibleEndpoint::AnthropicMessages => body_to_send,
|
||||
};
|
||||
|
||||
// Forward the (Phase A: identical) buffered bytes. reqwest
|
||||
// sets its own Content-Length from the body bytes — the
|
||||
// existing `build_forward_request_headers` already strips
|
||||
|
|
@ -942,6 +976,119 @@ fn is_sse_response(headers: &http::HeaderMap) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// PR-E4: OpenAI `prompt_cache_key` auto-injection helper.
|
||||
///
|
||||
/// Gates on [`AuthMode::Payg`] and the in-body
|
||||
/// `prompt_cache_key` skip rule, parses the body once, mutates if
|
||||
/// appropriate, and re-serialises. Returns the original `body` on
|
||||
/// any non-applicable path — every error / skip leaves the bytes
|
||||
/// untouched (Phase A passthrough invariant).
|
||||
///
|
||||
/// Logs `e4_skipped` for each skip reason and `e4_applied` with
|
||||
/// only the first [`KEY_PREFIX_LOG_LEN`] hex chars of the key
|
||||
/// (never the full key, which is identifying material).
|
||||
///
|
||||
/// [`KEY_PREFIX_LOG_LEN`]: cache_stabilization::openai_cache_key::KEY_PREFIX_LOG_LEN
|
||||
fn maybe_inject_openai_prompt_cache_key(
|
||||
body: bytes::Bytes,
|
||||
shape: cache_stabilization::openai_cache_key::OpenAiShape,
|
||||
auth_mode: AuthMode,
|
||||
request_id: &str,
|
||||
path: &str,
|
||||
) -> bytes::Bytes {
|
||||
use cache_stabilization::openai_cache_key::{
|
||||
inject_prompt_cache_key, InjectOutcome, SkipReason,
|
||||
};
|
||||
|
||||
// Auth-mode gate: only PAYG bodies are eligible. OAuth /
|
||||
// Subscription requests pass through byte-equal — synthesised
|
||||
// cache keys would look like cache-evasion to the upstream
|
||||
// and could void OAuth scopes pinned to `(account, model,
|
||||
// session)`.
|
||||
if !matches!(auth_mode, AuthMode::Payg) {
|
||||
tracing::info!(
|
||||
event = "e4_skipped",
|
||||
request_id = %request_id,
|
||||
path = %path,
|
||||
reason = "auth_mode",
|
||||
auth_mode = auth_mode.as_str(),
|
||||
"PR-E4: skipped prompt_cache_key injection (non-PAYG auth mode)"
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
// Parse for the inject step. Failure here is silent — the
|
||||
// dispatcher above already logged the parse outcome on its
|
||||
// own decision path; we don't want to double-log. The body
|
||||
// round-trips unchanged.
|
||||
let mut parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
|
||||
match inject_prompt_cache_key(&mut parsed, shape) {
|
||||
InjectOutcome::Applied { key_prefix } => {
|
||||
// Re-serialise. If serialization fails (would be very
|
||||
// unusual — we just successfully parsed), fall back
|
||||
// to the original bytes. No-silent-fallback rule: log
|
||||
// it loudly so a regression can't hide.
|
||||
match serde_json::to_vec(&parsed) {
|
||||
Ok(buf) => {
|
||||
tracing::info!(
|
||||
event = "e4_applied",
|
||||
request_id = %request_id,
|
||||
path = %path,
|
||||
key_prefix = %key_prefix,
|
||||
body_bytes_in = body.len(),
|
||||
body_bytes_out = buf.len(),
|
||||
"PR-E4: injected prompt_cache_key"
|
||||
);
|
||||
bytes::Bytes::from(buf)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
event = "e4_serialize_error",
|
||||
request_id = %request_id,
|
||||
path = %path,
|
||||
error = %e,
|
||||
"PR-E4: re-serialize after injection failed; forwarding original bytes"
|
||||
);
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
InjectOutcome::Skipped { reason } => {
|
||||
// Log only the customer-visible KeyPresent skip; the
|
||||
// NotAnObject skip is structurally impossible past
|
||||
// the dispatcher gate but is surfaced separately for
|
||||
// operators chasing pathological inputs.
|
||||
match reason {
|
||||
SkipReason::KeyPresent => {
|
||||
tracing::info!(
|
||||
event = "e4_skipped",
|
||||
request_id = %request_id,
|
||||
path = %path,
|
||||
reason = SkipReason::KeyPresent.as_str(),
|
||||
"PR-E4: skipped prompt_cache_key injection (customer-set value preserved)"
|
||||
);
|
||||
}
|
||||
SkipReason::NotAnObject => {
|
||||
tracing::warn!(
|
||||
event = "e4_skipped",
|
||||
request_id = %request_id,
|
||||
path = %path,
|
||||
reason = SkipReason::NotAnObject.as_str(),
|
||||
"PR-E4: body is not a JSON object; passthrough"
|
||||
);
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the per-provider state machine over a stream of byte chunks.
|
||||
/// Lives in its own task; the byte path never waits on it.
|
||||
async fn run_sse_state_machine(
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ async fn passthrough_no_compression_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode so the prompt_cache_key auto-injection
|
||||
// hook short-circuits and the byte-equality invariant this test
|
||||
// pins is preserved. PAYG bodies are now mutated by E4 — see
|
||||
// `integration_e4_openai_cache_key.rs` for that coverage.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -221,6 +229,13 @@ async fn n_greater_than_one_passthrough() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality (E4 only
|
||||
// injects on PAYG). The n>1 skip semantics are independent
|
||||
// of auth mode.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -256,6 +271,13 @@ async fn stream_options_include_usage_preserved() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality. The
|
||||
// dispatcher never reads stream_options regardless of mode;
|
||||
// E4 only mutates on PAYG.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
396
crates/headroom-proxy/tests/integration_e4_openai_cache_key.rs
Normal file
396
crates/headroom-proxy/tests/integration_e4_openai_cache_key.rs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
//! Integration tests for PR-E4 OpenAI `prompt_cache_key`
|
||||
//! auto-injection.
|
||||
//!
|
||||
//! Boots a real Rust proxy in front of a wiremock upstream and
|
||||
//! exercises the four matrix points the spec calls out:
|
||||
//!
|
||||
//! 1. PAYG `/v1/chat/completions` request without `prompt_cache_key`
|
||||
//! → upstream sees an injected 32-hex-char key.
|
||||
//! 2. PAYG `/v1/responses` request without `prompt_cache_key`
|
||||
//! → upstream sees an injected key.
|
||||
//! 3. PAYG request WITH `prompt_cache_key` already set
|
||||
//! → upstream sees the customer-provided value (passthrough).
|
||||
//! 4. OAuth and Subscription requests
|
||||
//! → upstream sees byte-equal bytes (no key injection at all).
|
||||
//!
|
||||
//! For (3), passthrough means the customer's value is preserved
|
||||
//! AND the rest of the body keeps its byte shape.
|
||||
//! For (4), the body is byte-equal to the inbound bytes (the
|
||||
//! universal Phase A invariant).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::start_proxy_with;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Mount a `/v1/chat/completions` capture handler that records the
|
||||
/// upstream request body for later assertions.
|
||||
async fn mount_chat_capture(upstream: &MockServer) -> Arc<Mutex<Option<Vec<u8>>>> {
|
||||
let captured: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
||||
let captured_clone = captured.clone();
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(move |req: &wiremock::Request| {
|
||||
*captured_clone.lock().unwrap() = Some(req.body.clone());
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"ok":true}"#)
|
||||
})
|
||||
.mount(upstream)
|
||||
.await;
|
||||
captured
|
||||
}
|
||||
|
||||
/// Mount a `/v1/responses` capture handler.
|
||||
async fn mount_responses_capture(upstream: &MockServer) -> Arc<Mutex<Option<Vec<u8>>>> {
|
||||
let captured: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
||||
let captured_clone = captured.clone();
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(move |req: &wiremock::Request| {
|
||||
*captured_clone.lock().unwrap() = Some(req.body.clone());
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"ok":true}"#)
|
||||
})
|
||||
.mount(upstream)
|
||||
.await;
|
||||
captured
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.fold(String::with_capacity(64), |mut acc, b| {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_byte_equal(inbound: &[u8], received: &[u8]) {
|
||||
assert_eq!(
|
||||
inbound.len(),
|
||||
received.len(),
|
||||
"byte length mismatch: inbound={} received={}",
|
||||
inbound.len(),
|
||||
received.len(),
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_hex(inbound),
|
||||
sha256_hex(received),
|
||||
"SHA-256 mismatch — request was mutated",
|
||||
);
|
||||
}
|
||||
|
||||
fn captured_body(captured: &Arc<Mutex<Option<Vec<u8>>>>) -> Vec<u8> {
|
||||
captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("upstream should have captured a body")
|
||||
}
|
||||
|
||||
fn parse_json(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).expect("upstream body should be valid JSON")
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// PAYG chat completions: key injected
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn payg_chat_completions_injects_prompt_cache_key() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_chat_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PAYG: standard OpenAI sk- bearer token.
|
||||
.header("authorization", "Bearer sk-test-payg-key")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
let received_json = parse_json(&received);
|
||||
|
||||
let key = received_json
|
||||
.get("prompt_cache_key")
|
||||
.and_then(Value::as_str)
|
||||
.expect("upstream body should have prompt_cache_key on PAYG");
|
||||
assert_eq!(key.len(), 32, "key must be 32 hex chars, got {key:?}");
|
||||
assert!(
|
||||
key.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"key must be hex: {key}"
|
||||
);
|
||||
|
||||
// Other fields preserved structurally (parse-equivalent).
|
||||
assert_eq!(
|
||||
received_json.get("model").and_then(Value::as_str),
|
||||
Some("gpt-4o")
|
||||
);
|
||||
let inbound_json = parse_json(&body);
|
||||
assert_eq!(received_json.get("messages"), inbound_json.get("messages"));
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// PAYG responses: key injected
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn payg_responses_injects_prompt_cache_key() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_responses_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"instructions": "You are a helpful assistant.",
|
||||
"input": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer sk-test-payg-key")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
let received_json = parse_json(&received);
|
||||
|
||||
let key = received_json
|
||||
.get("prompt_cache_key")
|
||||
.and_then(Value::as_str)
|
||||
.expect("upstream body should have prompt_cache_key on PAYG /v1/responses");
|
||||
assert_eq!(key.len(), 32, "key must be 32 hex chars, got {key:?}");
|
||||
|
||||
// `instructions` and `input` preserved.
|
||||
assert_eq!(
|
||||
received_json.get("instructions").and_then(Value::as_str),
|
||||
Some("You are a helpful assistant.")
|
||||
);
|
||||
let inbound_json = parse_json(&body);
|
||||
assert_eq!(received_json.get("input"), inbound_json.get("input"));
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// PAYG with customer-set key: customer wins (passthrough)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn payg_chat_with_customer_set_key_preserves_value() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_chat_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"prompt_cache_key": "user-pinned-tenant-A",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer sk-test-payg-key")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
// The dispatcher's live-zone surgery must NOT have rewritten
|
||||
// the body (the live-zone is the latest user/tool message,
|
||||
// which here is below the byte threshold). And the E4 hook
|
||||
// must have skipped (customer key present). Net effect: bytes
|
||||
// round-trip byte-equal to inbound.
|
||||
assert_byte_equal(&body, &received);
|
||||
|
||||
let received_json = parse_json(&received);
|
||||
assert_eq!(
|
||||
received_json
|
||||
.get("prompt_cache_key")
|
||||
.and_then(Value::as_str),
|
||||
Some("user-pinned-tenant-A"),
|
||||
"customer-set prompt_cache_key must be preserved"
|
||||
);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// OAuth: no injection, byte-equal passthrough
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_chat_completions_no_injection_byte_equal() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_chat_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
// OAuth: 3-segment JWT shape. Headroom's auth_mode classifier
|
||||
// detects this and routes to AuthMode::OAuth, which the E4
|
||||
// hook short-circuits.
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
assert_byte_equal(&body, &received);
|
||||
|
||||
let received_json = parse_json(&received);
|
||||
assert!(
|
||||
received_json.get("prompt_cache_key").is_none(),
|
||||
"OAuth requests must not get prompt_cache_key injected; got {received_json}"
|
||||
);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Subscription: no injection, byte-equal passthrough
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_chat_completions_no_injection_byte_equal() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_chat_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
// Subscription clients: identified by user-agent prefix
|
||||
// (Codex CLI on `/v1/chat/completions` is the canonical case).
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer sk-test-payg-key")
|
||||
.header("user-agent", "codex-cli/1.0.0")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
assert_byte_equal(&body, &received);
|
||||
|
||||
let received_json = parse_json(&received);
|
||||
assert!(
|
||||
received_json.get("prompt_cache_key").is_none(),
|
||||
"Subscription requests must not get prompt_cache_key injected"
|
||||
);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// OAuth on /v1/responses: no injection, byte-equal passthrough
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_responses_no_injection_byte_equal() {
|
||||
let upstream = MockServer::start().await;
|
||||
let captured = mount_responses_capture(&upstream).await;
|
||||
let proxy = start_proxy_with(&upstream.uri(), |c| {
|
||||
c.compression = true;
|
||||
})
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"model": "gpt-4o",
|
||||
"instructions": "Be concise.",
|
||||
"input": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let body = serde_json::to_vec(&payload).unwrap();
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let received = captured_body(&captured);
|
||||
assert_byte_equal(&body, &received);
|
||||
|
||||
let received_json = parse_json(&received);
|
||||
assert!(
|
||||
received_json.get("prompt_cache_key").is_none(),
|
||||
"OAuth /v1/responses requests must not get prompt_cache_key injected"
|
||||
);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
|
@ -103,6 +103,14 @@ async fn v4a_patch_byte_equal_through_proxy() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -147,6 +155,14 @@ async fn local_shell_call_command_argv_array_preserved() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -190,6 +206,14 @@ async fn codex_phase_commentary_preserved() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -228,6 +252,14 @@ async fn codex_phase_final_answer_preserved() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -264,6 +296,14 @@ async fn compaction_item_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -296,6 +336,14 @@ async fn reasoning_encrypted_content_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -336,6 +384,14 @@ async fn function_call_arguments_string_preserved() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -386,6 +442,14 @@ async fn call_id_referenced_not_id() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -431,6 +495,14 @@ async fn apply_patch_output_below_2kb_no_compression() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -477,6 +549,14 @@ async fn apply_patch_output_above_2kb_compressed() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -538,6 +618,14 @@ async fn local_shell_output_compressed() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -588,6 +676,14 @@ async fn mcp_tool_call_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -623,6 +719,14 @@ async fn computer_call_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -663,6 +767,14 @@ async fn image_generation_call_no_log_redaction_in_test_mode() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -712,6 +824,14 @@ async fn unknown_item_type_logged_warning_byte_equal() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -768,6 +888,14 @@ async fn representative_request_round_trip() {
|
|||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality across the
|
||||
// proxy (E4 only injects prompt_cache_key on PAYG). These
|
||||
// dispatcher byte-fidelity tests pin the live-zone surgery,
|
||||
// independent of the E4 cache-stabilization hook.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -192,6 +192,13 @@ async fn streaming_request_bytes_byte_equal_upstream() {
|
|||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality (E4 only
|
||||
// injects prompt_cache_key on PAYG). These tests pin the
|
||||
// streaming-side byte fidelity, independent of E4.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -229,6 +236,13 @@ async fn streaming_response_round_trips_through_framer() {
|
|||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality (E4 only
|
||||
// injects prompt_cache_key on PAYG). These tests pin the
|
||||
// streaming-side byte fidelity, independent of E4.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -297,6 +311,13 @@ async fn streaming_pipeline_disabled_still_passes_bytes() {
|
|||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality (E4 only
|
||||
// injects prompt_cache_key on PAYG). These tests pin the
|
||||
// streaming-side byte fidelity, independent of E4.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -344,6 +365,13 @@ async fn streaming_request_no_compression_when_input_below_threshold() {
|
|||
.post(format!("{}/v1/responses", proxy.url()))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
// PR-E4: OAuth auth mode preserves byte-equality (E4 only
|
||||
// injects prompt_cache_key on PAYG). These tests pin the
|
||||
// streaming-side byte fidelity, independent of E4.
|
||||
.header(
|
||||
"authorization",
|
||||
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_bytes",
|
||||
)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue