Skip to main content

aphrodite/
proxy.rs

1//! aphrodite - Reverse proxy with Chat Completions API support.
2//!
3//! Two modes:
4//! - **Cache** (:9797): In-memory CCR, lightweight compression, no tool
5//!   injection. Passes most content through, only compresses very large outputs
6//!   (>8KB).
7//! - **Token** (:9798): SQLite CCR, aggressive compression, tool injection,
8//!   tool relay for bidirectional Hermes communication.
9//!
10//! Chat Completions API:
11//! - Forwards POST /v1/chat/completions to DeepSeek
12//! - Intercepts responses, compresses tool output via CCR
13//! - Does NOT inject the aphrodite_retrieve tool definition into response
14//!   tool_calls (that was tried and reverted - see Bug 18 in
15//!   `compress_chat_completion`); the Python plugin registers the tool
16//!   instead.
17
18use std::{
19	collections::{HashMap, VecDeque},
20	num::NonZeroUsize,
21	sync::{
22		Arc, Mutex,
23		atomic::{AtomicU64, AtomicUsize, Ordering},
24	},
25	time::Duration,
26};
27
28use axum::{
29	body::Body,
30	extract::State,
31	http::{Method, StatusCode},
32	response::{IntoResponse, Json, Response},
33};
34use bytes::Bytes;
35use reqwest::Client as HttpClient;
36use serde::{Deserialize, Serialize};
37use headroom_core::ccr::{
38	CcrStore,
39	backends::{in_memory::InMemoryCcrStore, sqlite::SqliteCcrStore},
40	compute_key,
41};
42use tokio_util::task::TaskTracker;
43use futures::StreamExt;
44
45/// API key wrapper with safe Debug and Display - never leaks to logs.
46#[derive(Clone)]
47pub struct Secret(pub(crate) String);
48
49impl std::fmt::Debug for Secret {
50	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51		write!(f, "[REDACTED]")
52	}
53}
54
55impl std::fmt::Display for Secret {
56	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57		write!(f, "[REDACTED]")
58	}
59}
60
61impl Secret {
62	/// Expose the raw API key value for use in HTTP headers.
63	pub fn expose(&self) -> &str {
64		&self.0
65	}
66}
67
68impl From<&str> for Secret {
69	fn from(s: &str) -> Self {
70		Secret(s.to_string())
71	}
72}
73
74impl From<String> for Secret {
75	fn from(s: String) -> Self {
76		Secret(s)
77	}
78}
79
80use crate::config::{Cli, CompressionConfig, ProxyMode, env_parse_warn};
81
82// ── Constants ───────────────────────────────────────────────────────
83
84/// Content size threshold for cache mode compression (8KB).
85const CACHE_COMPRESS_THRESHOLD: usize = 8192;
86/// Content size threshold for aphrodite mode compression (1KB).
87const TOKEN_COMPRESS_THRESHOLD: usize = 1024;
88/// Inline CCR threshold - content below this size is stored in the inline
89/// HashMap instead of SQLite/in-memory CCR backends, avoiding round-trip
90/// overhead.
91const INLINE_CCR_THRESHOLD: usize = 256;
92/// Chat Completions API path.
93const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";
94/// Max body size cached in `response_cache` (1 MB). `response_cache` is
95/// count-bounded (128 entries via LRU) but not byte-bounded (report 06 F7) -
96/// without this, 128 large completions (e.g. big code-generation responses)
97/// could hold well over 100 MB resident for a cache whose only purpose is
98/// avoiding a repeat upstream round-trip on an identical request.
99const RESPONSE_CACHE_MAX_BODY_BYTES: usize = 1024 * 1024;
100
101/// Non-streaming response body cap (report 02-T10). A single buffered
102/// `.bytes().await` can exhaust memory if the upstream returns a huge body
103/// (e.g. a full codebase in a single completion). Streamed (SSE) responses
104/// bypass this limit entirely - they're chunked at the protocol level.
105const RESPONSE_MAX_BODY_BYTES: usize = 64 * 1024 * 1024; // 64 MB
106
107/// Live-resolved compression thresholds (report 07 F2/F4/T15): env var >
108/// TOML `[compression]` value > compiled-in default, the same precedence
109/// pattern `apply_port_override` already uses for the listen port. Computed
110/// once at startup (`build_state`) and re-computed on every hot-reload
111/// (config-file watcher + `POST /reload`) so both actually change the live
112/// proxy instead of only re-parsing and logging.
113pub struct ResolvedThresholds {
114	pub cache: usize,
115	pub token: usize,
116	pub inline: usize,
117	pub code_multiplier: f64,
118}
119
120/// Resolve the four compression thresholds from env vars, the TOML
121/// `[compression]` table (if any), and the compiled-in defaults, in that
122/// precedence order.
123///
124/// `code_multiplier` defaults to `3.0`, not the old free-function's `2`: the
125/// prior default only existed because `APHRODITE_CODE_MULTIPLIER=3.0`
126/// silently failed a `usize` parse (report 07 F10) - every shipped TOML and
127/// doc has always said `3.0`, so `2` was a masked bug, not an intentional
128/// value, and this restores the value the project's own config always
129/// claimed.
130pub fn resolve_thresholds(compression: Option<&CompressionConfig>) -> ResolvedThresholds {
131	ResolvedThresholds {
132		cache: env_parse_warn::<usize>("APHRODITE_TOOL_THRESHOLD_CACHE")
133			.or_else(|| compression.and_then(|c| c.tool_threshold_cache).map(|v| v as usize))
134			.unwrap_or(CACHE_COMPRESS_THRESHOLD),
135		token: env_parse_warn::<usize>("APHRODITE_TOOL_THRESHOLD_TOKEN")
136			.or_else(|| compression.and_then(|c| c.tool_threshold_token).map(|v| v as usize))
137			.unwrap_or(TOKEN_COMPRESS_THRESHOLD),
138		inline: env_parse_warn::<usize>("APHRODITE_INLINE_THRESHOLD")
139			.or_else(|| compression.and_then(|c| c.inline_threshold).map(|v| v as usize))
140			.unwrap_or(INLINE_CCR_THRESHOLD),
141		code_multiplier: env_parse_warn::<f64>("APHRODITE_CODE_MULTIPLIER")
142			.or_else(|| compression.and_then(|c| c.code_multiplier))
143			.unwrap_or(3.0),
144	}
145}
146
147// ── spawn_blocking wrappers for CcrStore (rusqlite is blocking) ─────
148
149/// Wrapper for `ccr.get()` on a blocking thread.
150pub(crate) async fn ccr_get(ccr: &Arc<dyn CcrStore>, hash: &str) -> Option<String> {
151	let ccr = ccr.clone();
152	let hash = hash.to_owned();
153	tokio::task::spawn_blocking(move || ccr.get(&hash)).await.unwrap_or(None)
154}
155
156/// Wrapper for `ccr.put()` on a blocking thread.
157/// Store `content` under `hash` in the CCR backend. Returns whether the
158/// store actually succeeded (F4) - a caller that discards this and replaces
159/// the original content with a marker anyway ships a marker whose hash
160/// resolves to nothing the moment the store is full/locked/panics, which is
161/// permanent data loss (the original content never reached the client).
162async fn ccr_put(ccr: &Arc<dyn CcrStore>, hash: &str, content: &str) -> bool {
163	let ccr = ccr.clone();
164	let hash = hash.to_owned();
165	let content = content.to_owned();
166	tokio::task::spawn_blocking(move || ccr.put(&hash, &content))
167		.await
168		.unwrap_or(false)
169}
170
171/// Wrapper for `ccr.del()` on a blocking thread.
172async fn ccr_del(ccr: &Arc<dyn CcrStore>, hash: &str) -> bool {
173	let ccr = ccr.clone();
174	let hash = hash.to_owned();
175	tokio::task::spawn_blocking(move || ccr.del(&hash)).await.unwrap_or(false)
176}
177
178/// Wrapper for `ccr.len()` on a blocking thread.
179async fn ccr_len(ccr: &Arc<dyn CcrStore>) -> usize {
180	let ccr = ccr.clone();
181	tokio::task::spawn_blocking(move || ccr.len()).await.unwrap_or(0)
182}
183
184// ── State ──────────────────────────────────────────────────────────
185
186/// Shared proxy state: upstream client config, CCR backend, and all
187/// counters/caches used by request handlers. Wrapped in `Arc` and cloned
188/// into every axum handler.
189pub struct AppState {
190	pub client: HttpClient,
191	/// 02-F2: a separate client with no total `.timeout()`, used only for
192	/// requests the caller marked `"stream": true` before sending - reqwest's
193	/// client-level timeout bounds the WHOLE request lifetime including
194	/// consuming the response body, not just headers, so a chat completion
195	/// streaming tokens past `cli.timeout` (300s default) had its
196	/// `bytes_stream()` killed mid-answer on the shared, bounded `client`.
197	/// Hang protection here comes from `connect_timeout` + `tcp_keepalive`
198	/// instead of a total deadline - deliberate, since a legitimately slow
199	/// but still-progressing stream must not be cut off.
200	pub stream_client: HttpClient,
201	pub api_url: String,
202	pub model: String,
203	pub api_key: Secret,
204	pub ccr: Option<Arc<dyn CcrStore>>,
205	pub add_markers: bool,
206	pub mode: ProxyMode,
207	pub tool_relay: bool,
208	pub notify_url: Option<String>,
209	pub notify_key: Option<String>,
210	/// Dev mode - verbose logging.
211	pub dev: bool,
212
213	// Structured debug
214	/// Ring buffer of last 50 request summaries
215	/// Lock uses `.lock().map(...).unwrap_or_default()` - poison is safely
216	/// tolerated: a poisoned mutex returns Err, and unwrap_or_default gives
217	/// an empty/logical-default so the proxy stays up.
218	pub request_history: std::sync::Mutex<VecDeque<serde_json::Value>>,
219	/// Inline CCR for tiny entries - no round-trip needed (< INLINE_CCR_THRESHOLD
220	/// bytes). Lock uses `.lock().map(...)` - same poison safety pattern.
221	/// Bounded to 1024 entries via LruCache to prevent unbounded memory growth.
222	pub inline_ccr: std::sync::Mutex<lru::LruCache<String, String>>,
223
224	// Stats
225	/// Latency histogram buckets (microseconds): 1ms, 10ms, 100ms, 1s, 10s
226	pub latency_buckets: [AtomicU64; 5],
227	/// Running total latency in microseconds for Prometheus _sum
228	pub total_latency_micros: AtomicU64,
229	/// Track last N errors for hot-path analysis
230	/// Mapped through `.lock().map(...)` - a poisoned lock returns Err and
231	/// unwrap_or_default provides an empty Vec so error recording degrades
232	/// gracefully without crashing the proxy.
233	pub last_errors: std::sync::Mutex<VecDeque<String>>,
234	/// Compression decision counters by content type
235	/// Uses `.lock().map(...)` - poison tolerant by design.
236	pub compressions_by_type: std::sync::Mutex<std::collections::HashMap<String, u64>>,
237
238	// Stats
239	pub requests_total: AtomicU64,
240	pub requests_compressed: AtomicU64,
241	/// Cumulative bytes saved by compression/caching, despite the name
242	/// (report 05 F5: the field is exposed as `tokens_saved` in `/stats` and
243	/// that external API name is kept for compatibility, but every call site
244	/// now accumulates raw BYTES - originally-removed content length minus
245	/// whatever replaced it (a rendered marker, or nothing at all on a full
246	/// cache hit) - never a token estimate. Previously one site divided by 4
247	/// to estimate tokens while every other site counted bytes, making the
248	/// counter internally inconsistent by 4x; another subtracted the bare
249	/// 40-char hash length instead of the actual (much longer) marker length,
250	/// overstating savings.
251	pub tokens_saved: AtomicU64,
252	pub ccr_hits: AtomicU64,
253	pub ccr_misses: AtomicU64,
254	pub ccr_created: AtomicU64,
255	pub tool_relay_calls: AtomicU64,
256	pub compression_ratio_ema: AtomicU64, // ×100 for EMA of compression ratio
257
258	// LLM API response cache (model+messages → compressed response)
259	/// LRU cache: hash(model+messages) → (inserted-at, serialized response body).
260	/// F5 (report 06): entries never expired on their own - a marker minted at
261	/// minute 0 of a long session was replayed unchanged at minute 90, silently
262	/// diverging from what a fresh (possibly temperature>0) upstream call would
263	/// return. Checked against `response_cache_ttl` on the hit path.
264	pub response_cache: std::sync::Mutex<lru::LruCache<u64, (std::time::Instant, Vec<u8>)>>,
265	/// TTL applied to `response_cache` entries - reuses `cli.ccr_ttl_seconds`
266	/// so cached LLM responses don't outlive the CCR content they were
267	/// derived from by a different, uncoordinated lifetime.
268	pub response_cache_ttl: std::time::Duration,
269	pub cache_hits: AtomicU64,
270	pub cache_misses: AtomicU64,
271
272	/// Tracks async background tasks (tool relay callbacks, CCR notifications)
273	/// so shutdown waits for them to complete before exiting.
274	pub task_tracker: TaskTracker,
275
276	/// Headroom fill percentage (×100, 0-10000). Updated after each
277	/// compression. Derived from compression_ratio_ema: higher compression =
278	/// lower fill = more headroom. Used by the Python plugin to set
279	/// x-headroom-budget for adaptive compression.
280	pub fill_pct: AtomicU64,
281
282	// Extended metrics
283	pub inline_ccr_hits: AtomicU64,
284	pub inline_ccr_misses: AtomicU64,
285	pub tool_relay_success: AtomicU64,
286	pub tool_relay_failure: AtomicU64,
287	pub notify_success: AtomicU64,
288	pub notify_failure: AtomicU64,
289	pub upstream_errors_4xx: AtomicU64,
290	pub upstream_errors_5xx: AtomicU64,
291	pub upstream_timeouts: AtomicU64,
292	/// Non-timeout transport failures (connection refused, DNS, TLS) - split
293	/// from `upstream_timeouts` (F17), which previously counted every kind
294	/// of transport error as a "timeout".
295	pub upstream_connect_errors: AtomicU64,
296	/// 02-F9: mid-stream chunk errors on the SSE relay path - distinct from
297	/// `upstream_connect_errors`/`upstream_timeouts`, which only ever see
298	/// failures before the response status/headers arrive. Once
299	/// `Body::from_stream` owns the stream, a later chunk `Err` (the F2
300	/// failure mode: an upstream that hangs or drops mid-stream) was
301	/// otherwise invisible to every counter - a 200 status had already been
302	/// recorded, so an operator diagnosing "streams cut off" from `/stats`
303	/// saw nothing.
304	pub sse_stream_errors: AtomicU64,
305	pub ccr_store_entries: AtomicU64,
306	pub ccr_store_bytes: AtomicU64,
307	pub request_body_bytes: AtomicU64,
308	pub response_body_bytes: AtomicU64,
309	pub upstream_latency_micros: AtomicU64,
310
311	/// TTL cache for the `/health/upstream` probe result: `(ok, checked_at)`.
312	/// F19: without this, a monitor polling `/health/upstream` every 10-15s
313	/// re-probes the real upstream on every single call - the exact cost
314	/// class `Maintain/examples/08_health_upstream.py` documents (a live
315	/// upstream call was already removed from the plain `/health` endpoint
316	/// for this reason; `/health/upstream` just re-introduced it under a
317	/// different path).
318	pub upstream_health_cache: std::sync::Mutex<Option<(bool, std::time::Instant)>>,
319
320	/// Live compression thresholds (report 07 F2/F4/T15) - previously
321	/// `CACHE_COMPRESS_THRESHOLD`/`TOKEN_COMPRESS_THRESHOLD`/
322	/// `INLINE_CCR_THRESHOLD` were consts, so every shipped TOML's
323	/// `[compression]` table was parsed and then silently discarded; `POST
324	/// /reload` and the config-file watcher logged success while applying
325	/// nothing. Resolved once at startup (env > TOML > const default, see
326	/// `resolve_thresholds`) and updated in place by both the watcher and
327	/// `/reload` - this is what makes hot-reload real instead of theater.
328	pub cache_compress_threshold: AtomicUsize,
329	pub token_compress_threshold: AtomicUsize,
330	pub inline_ccr_threshold: AtomicUsize,
331	/// `code_multiplier`, ×100 for integer atomic storage (matches the
332	/// existing `compression_ratio_ema` ×100 convention above).
333	pub code_multiplier_x100: AtomicU64,
334}
335
336/// Estimate the effective compressed byte-size of `content` for EMA
337/// feedback purposes, using a simple byte-entropy heuristic.
338///
339/// Counts unique 3-byte trigrams in the first 4096 bytes.  Highly
340/// repetitive content (few unique trigrams) yields a small estimate
341/// → high compression ratio.  Near-random content yields an estimate
342/// close to the original size → ratio near 1×.
343///
344/// The estimate is clamped so the ratio never goes below 1.0× (a
345/// compressor can't expand content beyond the original size in the
346/// worst case - it stores the literal).
347fn estimate_compressed_size(content: &str) -> usize {
348	use std::collections::HashSet;
349
350	let bytes = content.as_bytes();
351	let sample = if bytes.len() <= 4096 { bytes } else { &bytes[..4096] };
352	if sample.len() < 3 {
353		// Too short for trigrams - just return a minimal size so the
354		// ratio is reasonable but not inflated.
355		return sample.len().max(40);
356	}
357
358	// Collect unique 3-byte trigrams.
359	let mut trigrams: HashSet<[u8; 3]> = HashSet::with_capacity(sample.len().min(4096));
360	for window in sample.windows(3) {
361		trigrams.insert([window[0], window[1], window[2]]);
362	}
363
364	let unique = trigrams.len().max(1);
365	let total = sample.len().saturating_sub(2).max(1);
366
367	// Compressibility: what fraction of the content is NOT unique.
368	// 0.0 = perfectly random (no compression), 1.0 = fully repetitive.
369	let uniqueness = unique as f64 / total as f64;
370	let compressibility = 1.0 - uniqueness;
371
372	// estimated_compressed = content * (1 - compressibility * 0.97) + overhead
373	// Clamped so that compressed never exceeds original (ratio ≥ 1×).
374	let overhead: f64 = 40.0;
375	let raw = bytes.len() as f64 * (1.0 - compressibility * 0.97) + overhead;
376	let est = raw.min(bytes.len() as f64).max(40.0);
377	est as usize
378}
379
380impl AppState {
381	pub fn stats_json(&self) -> serde_json::Value {
382		serde_json::json!({
383			"mode": match self.mode {
384				ProxyMode::Cache => "cache",
385				ProxyMode::Token => "token",
386			},
387			"proxy": "aphrodite",
388			"ccr_backend": if self.ccr.is_some() { "enabled" } else { "none" },
389			// Renamed from "tool_relay" (F12): a duplicate "tool_relay" key
390			// further down (the stats object) silently won in
391			// `serde_json::json!`'s map construction, so this boolean -
392			// whether tool relay is enabled at all - was never actually
393			// exposed; there was no way to distinguish "relay disabled"
394			// from "relay enabled, zero calls".
395			"tool_relay_enabled": self.tool_relay,
396			"requests": {
397				"total": self.requests_total.load(Ordering::Relaxed),
398				"compressed": self.requests_compressed.load(Ordering::Relaxed),
399			},
400			"tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
401			"ccr": {
402				"hits": self.ccr_hits.load(Ordering::Relaxed),
403				"misses": self.ccr_misses.load(Ordering::Relaxed),
404				"created": self.ccr_created.load(Ordering::Relaxed),
405			},
406			"tool_relay_calls": self.tool_relay_calls.load(Ordering::Relaxed),
407			"cache": {
408				"hits": self.cache_hits.load(Ordering::Relaxed),
409				"misses": self.cache_misses.load(Ordering::Relaxed),
410			},
411			"latency_buckets_us": [
412				self.latency_buckets[0].load(Ordering::Relaxed),
413				self.latency_buckets[1].load(Ordering::Relaxed),
414				self.latency_buckets[2].load(Ordering::Relaxed),
415				self.latency_buckets[3].load(Ordering::Relaxed),
416				self.latency_buckets[4].load(Ordering::Relaxed),
417			],
418			"total_latency_micros": self.total_latency_micros.load(Ordering::Relaxed),
419			"compressions_by_type": self.compressions_by_type.lock().map(|m| m.clone()).unwrap_or_default(),
420			"compression_ratio_ema": self.compression_ratio_ema.load(Ordering::Relaxed) as f64 / 100.0,
421			"last_errors": self.last_errors.lock().map(|v| v.iter().rev().take(5).cloned().collect::<Vec<_>>()).unwrap_or_default(),
422			"request_history": self.request_history.lock().map(|v| v.clone()).unwrap_or_default(),
423			"inline_ccr": {
424				"hits": self.inline_ccr_hits.load(Ordering::Relaxed),
425				"misses": self.inline_ccr_misses.load(Ordering::Relaxed),
426			},
427			"tool_relay": {
428				"total": self.tool_relay_calls.load(Ordering::Relaxed),
429				"success": self.tool_relay_success.load(Ordering::Relaxed),
430				"failure": self.tool_relay_failure.load(Ordering::Relaxed),
431			},
432			"notify": {
433				"success": self.notify_success.load(Ordering::Relaxed),
434				"failure": self.notify_failure.load(Ordering::Relaxed),
435			},
436			"upstream_errors": {
437				"4xx": self.upstream_errors_4xx.load(Ordering::Relaxed),
438				"5xx": self.upstream_errors_5xx.load(Ordering::Relaxed),
439				"timeouts": self.upstream_timeouts.load(Ordering::Relaxed),
440				// F17: non-timeout transport failures (connect refused, DNS,
441				// TLS) - previously folded into "timeouts" above.
442				"connect_errors": self.upstream_connect_errors.load(Ordering::Relaxed),
443				// 02-F9: mid-stream chunk errors on the SSE relay path.
444				"sse_stream_errors": self.sse_stream_errors.load(Ordering::Relaxed),
445			},
446			"ccr_store": {
447				"entries": self.ccr_store_entries.load(Ordering::Relaxed),
448				"bytes_approx": self.ccr_store_bytes.load(Ordering::Relaxed),
449			},
450			"body_bytes": {
451				"request": self.request_body_bytes.load(Ordering::Relaxed),
452				"response": self.response_body_bytes.load(Ordering::Relaxed),
453			},
454			"upstream_latency_micros": self.upstream_latency_micros.load(Ordering::Relaxed),
455		})
456	}
457
458	fn compress_threshold(&self) -> usize {
459		match self.mode {
460			ProxyMode::Cache => self.cache_compress_threshold.load(Ordering::Relaxed),
461			ProxyMode::Token => self.token_compress_threshold.load(Ordering::Relaxed),
462		}
463	}
464
465	/// Inline-vs-durable storage cutoff (report 07 F2/T15) - was the
466	/// `INLINE_CCR_THRESHOLD` const; now live-configurable via
467	/// `compression.inline_threshold`.
468	fn inline_ccr_threshold(&self) -> usize {
469		self.inline_ccr_threshold.load(Ordering::Relaxed)
470	}
471
472	/// How many times the base threshold for code content (report 07
473	/// F2/F10/T11/T15) - was a free fn parsing `APHRODITE_CODE_MULTIPLIER` as
474	/// `usize` (silently truncating the documented `3.0` to a parse failure
475	/// -> default 2); now live-configurable via `compression.code_multiplier`
476	/// and re-resolved as `f64` on every hot-reload.
477	fn code_multiplier(&self) -> f64 {
478		self.code_multiplier_x100.load(Ordering::Relaxed) as f64 / 100.0
479	}
480
481	/// Per-type threshold - code stays in context longer, logs compressed
482	/// aggressively.
483	fn threshold_for(&self, ct: &str) -> usize {
484		let base = self.compress_threshold();
485		// Noisy types: keep at base threshold - coding sessions need build output
486		// visible
487		match ct {
488			"linter" | "build_output" | "log" => return base,
489			_ => {},
490		}
491		// Auto-tune: adjust thresholds based on historical compression ratios
492		let ratio = self.compression_ratio_ema.load(Ordering::Relaxed) as f64 / 100.0;
493		let tune = if ratio > 20.0 {
494			// Very aggressive - raise thresholds to preserve more content
495			2.0
496		} else if ratio < 3.0 && ratio > 0.0 {
497			// Very conservative - lower thresholds to compress more
498			0.5
499		} else {
500			1.0
501		};
502		let base = (base as f64 * tune) as usize;
503		match ct {
504			"error" => base * 8,
505			"code_rust" | "code_python" | "code_go" | "code_js" | "code" => {
506				(base as f64 * self.code_multiplier()) as usize
507			},
508			"diff" | "git" => base * 2,
509			"text" => base * 2,
510			"tool_output" => base,
511			"json" => base,
512			_ => base,
513		}
514	}
515
516	fn update_compression_ratio(&self, original_len: usize, compressed_len: usize) {
517		if original_len == 0 || compressed_len == 0 {
518			return;
519		}
520		let ratio = (original_len as f64 / compressed_len as f64 * 100.0) as u64;
521		// Exponential moving average: new = 0.2 * ratio + 0.8 * old
522		let old = self.compression_ratio_ema.load(Ordering::Relaxed);
523		let new = ((ratio as f64 * 0.2) + (old as f64 * 0.8)) as u64;
524		self.compression_ratio_ema.store(new, Ordering::Relaxed);
525		// After each compression update, also update fill_pct for headroom feedback
526		// loop
527		self.compute_fill_pct();
528	}
529
530	/// Derive fill percentage from compression ratio EMA.
531	/// Higher compression ratio → lower fill → more headroom.
532	/// fill_pct = 100 - (ratio_ema / 20), clamped to [1..99].
533	fn compute_fill_pct(&self) {
534		let ratio_ema = self.compression_ratio_ema.load(Ordering::Relaxed);
535		let pct = if ratio_ema == 0 {
536			99u64
537		} else {
538			let raw = 100u64.saturating_sub(ratio_ema / 20);
539			raw.clamp(1, 99)
540		};
541		self.fill_pct.store(pct * 100, Ordering::Relaxed); // ×100 for precision
542	}
543
544	fn record_latency(&self, d: std::time::Duration) {
545		let us = d.as_micros() as u64;
546		let bucket = if us < 1_000 {
547			0
548		} else if us < 10_000 {
549			1
550		} else if us < 100_000 {
551			2
552		} else if us < 1_000_000 {
553			3
554		} else {
555			4
556		};
557		self.latency_buckets[bucket].fetch_add(1, Ordering::Relaxed);
558		self.total_latency_micros.fetch_add(us, Ordering::Relaxed);
559	}
560
561	fn record_error(&self, msg: String) {
562		if let Ok(mut v) = self.last_errors.lock() {
563			v.push_back(msg);
564			if v.len() > 100 {
565				v.pop_front();
566			}
567		}
568	}
569
570	fn record_compression(&self, ct: &str) {
571		if let Ok(mut m) = self.compressions_by_type.lock() {
572			*m.entry(ct.to_string()).or_insert(0) += 1;
573		}
574	}
575
576	fn record_request(&self, id: &str, method: &str, path: &str, status: u16, compressed: bool, elapsed_ms: u128) {
577		if let Ok(mut hist) = self.request_history.lock() {
578			hist.push_back(serde_json::json!({
579				"id": id,
580				"method": method,
581				"path": path,
582				"status": status,
583				"compressed": compressed,
584				"elapsed_ms": elapsed_ms,
585			}));
586			if hist.len() > 50 {
587				hist.pop_front();
588			}
589		}
590	}
591}
592
593// ── Tool relay types ────────────────────────────────────────────────
594
595/// Inbound request body for `POST /tool_relay`: a Hermes-side tool call to
596/// execute against this proxy's CCR state (e.g. `aphrodite_retrieve`).
597#[derive(Debug, Deserialize)]
598pub struct ToolRelayRequest {
599	pub tool: String,
600	pub params: serde_json::Value,
601	pub callback_url: Option<String>,
602}
603
604/// Response for a tool relay call. When `callback_url` was set on the
605/// request, the call runs asynchronously and this comes back immediately
606/// with `async_call:true` and no `result` - the real result is POSTed to
607/// the callback URL later.
608#[derive(Debug, Serialize)]
609pub struct ToolRelayResponse {
610	pub success: bool,
611	pub result: Option<serde_json::Value>,
612	pub error: Option<String>,
613	pub async_call: bool,
614}
615
616// ── CCR management types ────────────────────────────────────────────
617
618/// Inbound request body for `POST /ccr/create`: store `content` under an
619/// optional caller-supplied `key` (defaults to the content hash).
620#[derive(Debug, Deserialize)]
621pub struct CcrCreateRequest {
622	pub content: String,
623	pub key: Option<String>,
624	pub ttl_seconds: Option<u64>,
625	pub tags: Option<Vec<String>>,
626}
627
628/// Response for `POST /ccr/create`, reporting the resulting hash and the
629/// size reduction achieved.
630#[derive(Debug, Serialize)]
631pub struct CcrCreateResponse {
632	pub hash: String,
633	pub token_savings_ratio: f64,
634	pub original_size: usize,
635	pub compressed_size: usize,
636	pub marker_size: usize,
637}
638
639/// Webhook payload POSTed to `notify_url` when a new CCR entry is created,
640/// so external subscribers (e.g. Hermes) can track store growth without
641/// polling.
642#[derive(Debug, Serialize)]
643pub struct CcrNotification {
644	pub event: String,
645	pub hash: String,
646	pub created_at: u64,
647	pub ttl: u64,
648	pub tags: Vec<String>,
649}
650
651// ── Build state ─────────────────────────────────────────────────────
652
653/// Construct `AppState` from CLI config: builds the tuned HTTP client,
654/// opens the CCR backend appropriate for `cli.mode` (SQLite for Token,
655/// in-memory for Cache), and zeroes all counters.
656pub async fn build_state(cli: &Cli, compression: Option<&CompressionConfig>) -> anyhow::Result<AppState> {
657	// Tuned HttpClient for high-concurrency API proxy workload.
658	// Default pool: 100 idle connections per host, 90s idle timeout, keepalive.
659	let client = HttpClient::builder()
660		.timeout(std::time::Duration::from_secs(cli.timeout))
661		.connect_timeout(std::time::Duration::from_secs(10))
662		.pool_max_idle_per_host(100)
663		.pool_idle_timeout(std::time::Duration::from_secs(90))
664		.tcp_keepalive(std::time::Duration::from_secs(60))
665		.build()?;
666	// 02-F2: same tuning, no total timeout - see `AppState::stream_client`'s
667	// doc comment.
668	let stream_client = HttpClient::builder()
669		.connect_timeout(std::time::Duration::from_secs(10))
670		.pool_max_idle_per_host(100)
671		.pool_idle_timeout(std::time::Duration::from_secs(90))
672		.tcp_keepalive(std::time::Duration::from_secs(60))
673		.build()?;
674
675	let ccr: Option<Arc<dyn CcrStore>> = match cli.mode {
676		ProxyMode::Token if !cli.no_ccr_marker => {
677			let db_path = cli.ccr_db_path.as_ref().map_or_else(
678				|| {
679					dirs::home_dir()
680						.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
681						.join(".hermes")
682						.join("aphrodite")
683						.join("ccr.db")
684				},
685				|p| p.clone(),
686			);
687			// Ensure parent directories exist before opening SQLite DB.
688			// Without this, a missing ~/.hermes/aphrodite/ directory causes
689			// the token proxy to fail silently at startup while the cache
690			// proxy continues running - a partial-failure that's invisible
691			// to the plugin because stderr is piped to DEVNULL.
692			if let Some(parent) = db_path.parent() {
693				std::fs::create_dir_all(parent)
694					.map_err(|e| anyhow::anyhow!("SQLite CCR: cannot create directory {}: {}", parent.display(), e))?;
695			}
696			let store = SqliteCcrStore::open(&db_path, cli.ccr_ttl_seconds)
697				.map_err(|e| anyhow::anyhow!("SQLite CCR: {}", e))?;
698			Some(Arc::new(store))
699		},
700		ProxyMode::Cache => {
701			let store =
702				InMemoryCcrStore::with_capacity_and_ttl(10_000, std::time::Duration::from_secs(cli.ccr_ttl_seconds));
703			Some(Arc::new(store))
704		},
705		_ => None,
706	};
707
708	let thresholds = resolve_thresholds(compression);
709
710	Ok(AppState {
711		client,
712		stream_client,
713		api_url: cli.api_url.clone(),
714		model: cli.model.clone(),
715		api_key: cli.api_key.clone().into(),
716		ccr,
717		add_markers: !cli.no_ccr_marker,
718		mode: cli.mode,
719		tool_relay: cli.tool_relay,
720		notify_url: cli.notify_url.clone(),
721		notify_key: cli.notify_key.clone(),
722		dev: cli.dev,
723		latency_buckets: [
724			AtomicU64::new(0),
725			AtomicU64::new(0),
726			AtomicU64::new(0),
727			AtomicU64::new(0),
728			AtomicU64::new(0),
729		],
730		total_latency_micros: AtomicU64::new(0),
731		last_errors: Mutex::new(VecDeque::new()),
732		compressions_by_type: Mutex::new(HashMap::new()),
733		request_history: Mutex::new(VecDeque::new()),
734		inline_ccr: Mutex::new(lru::LruCache::new(NonZeroUsize::new(1024).unwrap())),
735		requests_total: AtomicU64::new(0),
736		requests_compressed: AtomicU64::new(0),
737		tokens_saved: AtomicU64::new(0),
738		ccr_hits: AtomicU64::new(0),
739		ccr_misses: AtomicU64::new(0),
740		ccr_created: AtomicU64::new(0),
741		tool_relay_calls: AtomicU64::new(0),
742		compression_ratio_ema: AtomicU64::new(200), // initial: 2.0x - conservative, avoids startup scale-up
743		response_cache: Mutex::new(lru::LruCache::new(NonZeroUsize::new(128).unwrap())),
744		response_cache_ttl: std::time::Duration::from_secs(cli.ccr_ttl_seconds),
745		cache_hits: AtomicU64::new(0),
746		cache_misses: AtomicU64::new(0),
747		fill_pct: AtomicU64::new(9000), // 90.00% - moderate fill initial default
748		task_tracker: TaskTracker::new(),
749
750		inline_ccr_hits: AtomicU64::new(0),
751		inline_ccr_misses: AtomicU64::new(0),
752		tool_relay_success: AtomicU64::new(0),
753		tool_relay_failure: AtomicU64::new(0),
754		notify_success: AtomicU64::new(0),
755		notify_failure: AtomicU64::new(0),
756		upstream_errors_4xx: AtomicU64::new(0),
757		upstream_errors_5xx: AtomicU64::new(0),
758		upstream_timeouts: AtomicU64::new(0),
759		upstream_connect_errors: AtomicU64::new(0),
760		sse_stream_errors: AtomicU64::new(0),
761		ccr_store_entries: AtomicU64::new(0),
762		ccr_store_bytes: AtomicU64::new(0),
763		request_body_bytes: AtomicU64::new(0),
764		response_body_bytes: AtomicU64::new(0),
765		upstream_latency_micros: AtomicU64::new(0),
766		upstream_health_cache: std::sync::Mutex::new(None),
767		cache_compress_threshold: AtomicUsize::new(thresholds.cache),
768		token_compress_threshold: AtomicUsize::new(thresholds.token),
769		inline_ccr_threshold: AtomicUsize::new(thresholds.inline),
770		code_multiplier_x100: AtomicU64::new((thresholds.code_multiplier * 100.0) as u64),
771	})
772}
773
774// ── Main proxy handler ──────────────────────────────────────────────
775
776/// 02-F2: does this request body ask for a streamed (SSE) response? Checked
777/// before sending so `proxy_handler` can pick `state.stream_client` (no
778/// total timeout) instead of the bounded `state.client` - the upstream
779/// `Content-Type` isn't known until headers come back, by which point a
780/// bounded client's timeout is already ticking against the whole response.
781fn body_wants_stream(body: &[u8]) -> bool {
782	serde_json::from_slice::<serde_json::Value>(body)
783		.ok()
784		.and_then(|v| v.get("stream").and_then(|s| s.as_bool()))
785		.unwrap_or(false)
786}
787
788/// Compute a cache key from a Chat Completions request body: hash(api_key +
789/// model + messages). Uses FNV-1a (deterministic across restarts, unlike
790/// DefaultHasher). Includes api_key to prevent cross-user cache collision.
791/// Returns None if the body can't be parsed as JSON or lacks model/messages.
792fn cache_key_from_body(body: &[u8], api_key: &str) -> Option<u64> {
793	let v: serde_json::Value = serde_json::from_slice(body).ok()?;
794	// F3: never cache a streamed request - the cached entry is a single
795	// buffered JSON body, replayed with `Content-Type: application/json`,
796	// which is nothing like an SSE stream a `"stream": true` client expects.
797	if v.get("stream").and_then(|s| s.as_bool()).unwrap_or(false) {
798		return None;
799	}
800	// `model`/`messages` must both be present for this to be a valid,
801	// cacheable chat-completion request.
802	v.get("model")?.as_str()?;
803	v.get("messages")?;
804	// F3: the key used to hash only api_key+model+messages, so two requests
805	// differing solely in `tools`, `tool_choice`, `temperature`, `top_p`,
806	// `n`, or `response_format` collided and got served each other's cached
807	// response. Include every field that changes what a valid response can
808	// look like, in a fixed, canonical order (serde_json's key order from
809	// `v` itself is not guaranteed stable across equivalent requests).
810	let mut parts: Vec<u8> = Vec::new();
811	parts.extend_from_slice(api_key.as_bytes());
812	for (label, val) in [
813		("model", v.get("model")),
814		("messages", v.get("messages")),
815		("tools", v.get("tools")),
816		("tool_choice", v.get("tool_choice")),
817		("temperature", v.get("temperature")),
818		("top_p", v.get("top_p")),
819		("n", v.get("n")),
820		("response_format", v.get("response_format")),
821	] {
822		parts.push(b':');
823		parts.extend_from_slice(label.as_bytes());
824		parts.push(b'=');
825		if let Some(val) = val {
826			parts.extend_from_slice(serde_json::to_string(val).ok()?.as_bytes());
827		}
828	}
829	// FNV-1a 64-bit hash - deterministic across process restarts
830	Some(fnv1a_64(&parts))
831}
832
833/// Look up `ck` in `state.response_cache`, treating an entry older than
834/// `state.response_cache_ttl` as a miss and evicting it (report 06 F5) -
835/// without this, a marker minted at minute 0 of a long session gets its
836/// cached response replayed unchanged at minute 90, silently diverging from
837/// what a fresh (possibly temperature>0) upstream call would return.
838fn response_cache_get(state: &AppState, ck: u64) -> Option<Vec<u8>> {
839	state.response_cache.lock().ok().and_then(|mut cache| {
840		let expired = cache
841			.peek(&ck)
842			.map(|(inserted_at, _)| inserted_at.elapsed() >= state.response_cache_ttl)
843			.unwrap_or(false);
844		if expired {
845			cache.pop(&ck);
846			None
847		} else {
848			cache.get(&ck).map(|(_, body)| body.clone())
849		}
850	})
851}
852
853/// Copy upstream response headers onto `builder`, skipping hop-by-hop
854/// headers (F5) - `content-length` is always skipped too since the caller
855/// may be sending a re-serialized body of a different length than upstream's,
856/// and `content-type`/the two `X-Aphrodite-*` headers the caller sets itself
857/// afterward win if there's a name collision (axum keeps both; callers add
858/// their own explicit `content-type` after calling this).
859fn copy_upstream_headers(
860	mut builder: axum::http::response::Builder,
861	upstream_headers: &reqwest::header::HeaderMap,
862) -> axum::http::response::Builder {
863	const SKIP: &[&str] = &[
864		"content-length",
865		"content-type",
866		"transfer-encoding",
867		"connection",
868		"keep-alive",
869	];
870	for (name, value) in upstream_headers.iter() {
871		if SKIP.contains(&name.as_str()) {
872			continue;
873		}
874		if let Ok(v) = axum::http::HeaderValue::from_bytes(value.as_bytes()) {
875			builder = builder.header(name.as_str(), v);
876		}
877	}
878	builder
879}
880
881/// Accumulate the full response body with a byte cap (report 02-T10).
882/// Replaces the single unbounded `response.bytes().await` in the
883/// non-streaming branch - returns a 502 error if the upstream exceeds
884/// `max_bytes`, protecting the proxy's memory from a single huge response.
885async fn accumulate_body(response: reqwest::Response, max_bytes: usize) -> Result<bytes::Bytes, String> {
886	let mut buf = Vec::new();
887	let mut stream = response.bytes_stream();
888	while let Some(chunk) = stream.next().await {
889		match chunk {
890			Ok(b) => {
891				if buf.len() + b.len() > max_bytes {
892					return Err(format!("response body exceeded {} MB limit", max_bytes / (1024 * 1024)));
893				}
894				buf.extend_from_slice(&b);
895			},
896			Err(e) => return Err(format!("body read: {}", e)),
897		}
898	}
899	Ok(bytes::Bytes::from(buf))
900}
901
902/// FNV-1a 64-bit hash over bytes. Deterministic across restarts.
903fn fnv1a_64(bytes: &[u8]) -> u64 {
904	const FNV_OFFSET: u64 = 14695981039346656037;
905	const FNV_PRIME: u64 = 1099511628211;
906	let mut hash = FNV_OFFSET;
907	for &b in bytes {
908		hash ^= b as u64;
909		hash = hash.wrapping_mul(FNV_PRIME);
910	}
911	hash
912}
913
914/// T10 (F6): does this upstream `content-type` mark an SSE response that must
915/// be streamed chunk-by-chunk rather than buffered (a buffered SSE body is
916/// unparseable by an SSE client)? Prefix match, not exact - real servers
917/// append `; charset=utf-8` and similar.
918fn is_sse(content_type: Option<&axum::http::HeaderValue>) -> bool {
919	content_type
920		.map(|ct| ct.as_bytes().starts_with(b"text/event-stream"))
921		.unwrap_or(false)
922}
923
924/// Catch-all proxy handler - forwards any request to DeepSeek.
925/// Specifically handles Chat Completions API at /v1/chat/completions.
926pub async fn proxy_handler(
927	State(state): State<Arc<AppState>>,
928	method: Method,
929	path: axum::extract::OriginalUri,
930	headers: axum::http::HeaderMap,
931	body: Bytes,
932) -> impl IntoResponse {
933	state.requests_total.fetch_add(1, Ordering::Relaxed);
934	state.request_body_bytes.fetch_add(body.len() as u64, Ordering::Relaxed);
935	let t0 = std::time::Instant::now();
936	let req_id = uuid::Uuid::new_v4().to_string();
937	let req_id_short = &req_id[..8];
938
939	if state.dev {
940		// Log incoming headers
941		let mut hdr_log = String::new();
942		for (k, v) in headers.iter() {
943			let val = v.to_str().unwrap_or("?");
944			if k.as_str().to_lowercase() != "authorization" {
945				hdr_log.push_str(&format!("  {}: {}", k.as_str(), if val.len() > 80 { &val[..80] } else { val }));
946			} else {
947				hdr_log.push_str("  authorization: [REDACTED]");
948			}
949			hdr_log.push('\n');
950		}
951		tracing::info!(
952			id = %req_id_short,
953			method = %method,
954			path = %path.path(),
955			body_len = body.len(),
956			headers = %hdr_log,
957			">>> REQ"
958		);
959	}
960
961	// F10: forward the query string too - `path.path()` excludes it, so any
962	// OpenAI-compatible endpoint using query params (e.g. `GET
963	// /v1/models?limit=5`) had it silently dropped by this catch-all.
964	let deepseek_path_and_query = path
965		.0
966		.path_and_query()
967		.map(|pq| pq.as_str())
968		.unwrap_or_else(|| path.path())
969		.trim_start_matches('/');
970	let url = format!("{}/{}", state.api_url.trim_end_matches('/'), deepseek_path_and_query);
971
972	let is_chat_completion = path.path().trim_start_matches('/') == CHAT_COMPLETIONS_PATH.trim_start_matches('/');
973
974	let body_vec = body.to_vec();
975	let cache_key = if is_chat_completion {
976		cache_key_from_body(&body_vec, state.api_key.expose())
977	} else {
978		None
979	};
980	// Check LLM API response cache before upstream call
981	if let Some(ck) = cache_key {
982		let cached_body = response_cache_get(&state, ck);
983		if let Some(cached_body) = cached_body {
984			state.cache_hits.fetch_add(1, Ordering::Relaxed);
985			// Bytes throughout (report 05 F5): a full upstream round-trip
986			// was avoided, so the whole cached response body counts as
987			// saved - previously this divided by 4 to estimate a token
988			// count while every other `tokens_saved` site counted raw
989			// bytes, making the field internally inconsistent by 4x.
990			state.tokens_saved.fetch_add(cached_body.len() as u64, Ordering::Relaxed);
991			if state.dev {
992				tracing::info!(
993					id = %req_id_short,
994					cached_len = cached_body.len(),
995					"<<< CACHE HIT"
996				);
997			}
998			// F18: cache hits used to skip both of these entirely, so
999			// `/history` and the latency histogram never saw them - the p50
1000			// skewed upward (only ever measuring cache MISSES) and the
1001			// request-history ring buffer under-reported real traffic.
1002			state.record_latency(t0.elapsed());
1003			state.record_request(req_id_short, method.as_str(), path.path(), 200, false, t0.elapsed().as_millis());
1004			return Response::builder()
1005				.status(StatusCode::OK)
1006				.header("Content-Type", "application/json; charset=utf-8")
1007				.header("X-Aphrodite-Cache", "HIT")
1008				.header("X-Aphrodite-Fill-Pct", {
1009					let v = state.fill_pct.load(Ordering::Relaxed) as f64 / 100.0;
1010					if v.is_finite() { format!("{:.1}", v) } else { "0.0".to_string() }
1011				})
1012				.body(Body::from(cached_body))
1013				.unwrap();
1014		} else {
1015			state.cache_misses.fetch_add(1, Ordering::Relaxed);
1016			if state.dev {
1017				tracing::info!(
1018					id = %req_id_short,
1019					"<<< CACHE MISS"
1020				);
1021			}
1022		}
1023	}
1024	let mut upstream_result = Err("unreachable".to_string());
1025	// F17: which counter the final error increments - `upstream_timeouts`
1026	// used to count every kind of transport failure (connection refused,
1027	// DNS failure, TLS error) as a "timeout", which is a real metrics lie
1028	// for anyone diagnosing outages from `/metrics`.
1029	let mut final_error_was_timeout = false;
1030	// 02-F2: a `"stream": true` request goes out on `stream_client` (no
1031	// total timeout), not the bounded `client` - see its doc comment.
1032	let http_client = if body_wants_stream(&body_vec) { &state.stream_client } else { &state.client };
1033	// F10 fix (above) builds `body_vec` once (one Vec alloc). Convert to
1034	// `Bytes` here — outside the retry loop — so each attempt clones in O(1)
1035	// (refcount) instead of copying the whole payload. The no-retry common
1036	// path pays a single buffer move, not the two full copies the prior
1037	// `body_vec.clone()` forced on every request (bug 18-P9: up to 4 copies
1038	// of a 1MB body under retry load).
1039	let body_bytes = bytes::Bytes::from(body_vec);
1040	for attempt in 1..=3u32 {
1041		let req = http_client
1042			.request(method.clone(), &url)
1043			.header("Content-Type", "application/json; charset=utf-8")
1044			.header("Accept", "application/json")
1045			.header("Authorization", format!("Bearer {}", state.api_key.expose()));
1046		let mut req = req;
1047		for (key, val) in headers.iter() {
1048			let k = key.as_str().to_lowercase();
1049			// Strip (F5/F18): `content-type`/`accept` are forced above already -
1050			// forwarding the client's own values too would append duplicate
1051			// headers (reqwest's `header()` appends, it doesn't replace), and
1052			// strict upstreams reject a duplicated `Content-Type`.
1053			// `accept-encoding` is stripped entirely (F5): this client is built
1054			// without gzip/brotli auto-decompression
1055			// (`Cargo.toml`'s reqwest features), so forwarding a client's
1056			// `Accept-Encoding: gzip` gets a compressed body back that this
1057			// proxy can't decode, fails to JSON-parse (compression silently
1058			// skipped), and returns to the caller as binary garbage labeled
1059			// `application/json`.
1060			if k != "host"
1061				&& k != "authorization"
1062				&& k != "content-length"
1063				&& k != "content-type"
1064				&& k != "accept"
1065				&& k != "accept-encoding"
1066				&& !k.starts_with("x-aphrodite-")
1067			{
1068				req = req.header(key, val);
1069			}
1070		}
1071	// `body_bytes` is the `Bytes` built once before the loop (see
1072	// above); clone per attempt is O(1) refcount, never a full copy.
1073	match req.body(body_bytes.clone()).send().await {
1074			Ok(r) => {
1075				upstream_result = Ok(r);
1076				break;
1077			},
1078			Err(e) => {
1079				// F17: only retry connect-phase failures - the request never
1080				// left, so resending is safe. A post-send request TIMEOUT
1081				// (`e.is_timeout()` after the body was already transmitted)
1082				// may have been accepted by the upstream; blindly retrying a
1083				// non-idempotent `POST /v1/chat/completions` risks double
1084				// token billing, and combined with the 300s per-attempt
1085				// timeout, 3 blind retries could hold a client for ~15
1086				// minutes. Non-connect errors now fail fast on the first
1087				// attempt instead.
1088				if attempt < 3 && e.is_connect() {
1089					let base_ms = 100 * 2u64.pow(attempt - 1);
1090					let jitter = rand::random::<f64>() * 0.5 + 0.75; // 0.75x to 1.25x
1091					let ms = (base_ms as f64 * jitter) as u64;
1092					tracing::warn!(attempt, backoff_ms = ms, "upstream retry after connect error: {}", e);
1093					tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1094				} else {
1095					final_error_was_timeout = e.is_timeout();
1096					upstream_result = Err(format!("{}", e));
1097					break;
1098				}
1099			},
1100		}
1101	}
1102	match upstream_result {
1103		Ok(response) => {
1104			let status = response.status();
1105			// Track upstream errors by status code
1106			let status_code = status.as_u16();
1107			if status_code >= 500 {
1108				state.upstream_errors_5xx.fetch_add(1, Ordering::Relaxed);
1109			} else if status_code >= 400 {
1110				state.upstream_errors_4xx.fetch_add(1, Ordering::Relaxed);
1111			}
1112			// F5: capture the full upstream response header map before
1113			// consuming the body, not just `content-type` - the proxy used
1114			// to rebuild the response with only `Content-Type`, silently
1115			// dropping `Retry-After`, `x-ratelimit-*`, `request-id`, etc.
1116			// that clients rely on for backoff and support.
1117			let upstream_headers = response.headers().clone();
1118			let content_type = upstream_headers.get("content-type").cloned();
1119
1120			// T10 (F6): SSE streaming - text/event-stream responses must be
1121			// forwarded chunk-by-chunk, not buffered into a single JSON blob
1122			// (which an SSE client can't parse). Skip compression + cache
1123			// entirely for this path.
1124			if is_sse(content_type.as_ref()) {
1125				// 02-F9: count bytes and observe mid-stream errors as chunks
1126				// flow through - once `Body::from_stream` owns the raw
1127				// `bytes_stream()`, a later chunk `Err` (F2's failure mode)
1128				// and every SSE byte were otherwise invisible to `/stats`.
1129				let state_for_stream = state.clone();
1130				let stream = response.bytes_stream().inspect(move |chunk| match chunk {
1131					Ok(bytes) => {
1132						state_for_stream
1133							.response_body_bytes
1134							.fetch_add(bytes.len() as u64, Ordering::Relaxed);
1135					},
1136					Err(_) => {
1137						state_for_stream.sse_stream_errors.fetch_add(1, Ordering::Relaxed);
1138					},
1139				});
1140				if state.dev {
1141					tracing::info!(id = %req_id_short, status = %status, "<<< STREAM (SSE)");
1142				}
1143				state.record_latency(t0.elapsed());
1144				state.record_request(
1145					req_id_short,
1146					method.as_str(),
1147					path.path(),
1148					status.as_u16(),
1149					false,
1150					t0.elapsed().as_millis(),
1151				);
1152				let mut builder = Response::builder().status(status);
1153				builder = copy_upstream_headers(builder, &upstream_headers);
1154				if let Some(ct) = content_type {
1155					builder = builder.header("Content-Type", ct);
1156				}
1157				builder = builder.header("X-Aphrodite-Streamed", "true");
1158				return builder.body(Body::from_stream(stream)).unwrap();
1159			}
1160
1161			// Buffer the full response body (non-streaming path). Cap at
1162			// RESPONSE_MAX_BODY_BYTES to prevent a single huge upstream
1163			// response from exhausting process memory.
1164			let resp_body = match accumulate_body(response, RESPONSE_MAX_BODY_BYTES).await {
1165				Ok(b) => b,
1166				Err(e) => {
1167					// 02-F8: the detailed error (which can embed the
1168					// upstream URL/host) goes server-side only, via
1169					// `record_error` - the client gets a generic message.
1170					// `/stats`'s `last_errors` still has the real string for
1171					// an operator to diagnose from.
1172					state.record_error(format!("body read: {}", e));
1173					return (
1174						StatusCode::BAD_GATEWAY,
1175						Json(serde_json::json!({"error": "upstream request failed"})),
1176					)
1177						.into_response();
1178				},
1179			};
1180
1181			// Track upstream latency (before compression)
1182			let upstream_elapsed = t0.elapsed().as_micros() as u64;
1183			state.upstream_latency_micros.fetch_add(upstream_elapsed, Ordering::Relaxed);
1184			// Track response body bytes
1185			state.response_body_bytes.fetch_add(resp_body.len() as u64, Ordering::Relaxed);
1186
1187			// Only compress Chat Completions responses
1188			let elapsed = t0.elapsed();
1189			if is_chat_completion && state.ccr.is_some() {
1190				// Extract headroom budget from inbound headers for compression
1191				// aggressiveness. `HeaderMap::get` is already case-insensitive
1192				// (F20), so the second lookup below used to be permanently
1193				// dead - removed rather than kept as misleading "just in case"
1194				// code implying case-sensitive matching.
1195				let headroom_budget = headers.get("x-headroom-budget").and_then(|v| v.to_str().ok());
1196				if state.dev && headroom_budget.is_some() {
1197					tracing::info!(
1198						id = %req_id_short,
1199						budget = %headroom_budget.unwrap_or(""),
1200						"headroom budget applied to compression threshold"
1201					);
1202				}
1203				if let Some(compressed) = compress_chat_completion(&state, &resp_body, headroom_budget).await {
1204					state.requests_compressed.fetch_add(1, Ordering::Relaxed);
1205					state.record_latency(elapsed);
1206					state.record_request(
1207						req_id_short,
1208						method.as_str(),
1209						path.path(),
1210						status.as_u16(),
1211						true,
1212						elapsed.as_millis(),
1213					);
1214					if state.dev {
1215						let elapsed = t0.elapsed();
1216						let comp_len = serde_json::to_vec(&compressed).map(|v| v.len()).unwrap_or(0);
1217						tracing::info!(
1218							id = %req_id_short,
1219							status = %status,
1220							original_len = resp_body.len(),
1221							compressed_len = comp_len,
1222							ratio = format!("{:.1}x", resp_body.len() as f64 / comp_len.max(1) as f64),
1223							elapsed_ms = elapsed.as_millis(),
1224							"<<< COMPRESSED"
1225						);
1226					}
1227					let body = serde_json::to_vec(&compressed).unwrap_or_else(|_| resp_body.to_vec());
1228					// Store in LLM response cache - only successful responses (F2):
1229					// caching an upstream 4xx/5xx here would replay it as a 200 to
1230					// every later identical request, and a client would parse the
1231					// error body as a real chat completion. TTL-stamped (F5,
1232					// report 06) so it's checked against `response_cache_ttl` on
1233					// the hit path instead of replaying forever.
1234					if let Some(ck) = cache_key {
1235						if status.is_success() && body.len() <= RESPONSE_CACHE_MAX_BODY_BYTES {
1236							if let Ok(mut cache) = state.response_cache.lock() {
1237								cache.put(ck, (std::time::Instant::now(), body.clone()));
1238							}
1239						}
1240					}
1241					let mut builder = Response::builder().status(status);
1242					builder = copy_upstream_headers(builder, &upstream_headers);
1243					return builder
1244						.header("Content-Type", "application/json; charset=utf-8")
1245						.header("X-Aphrodite-Compressed", "true")
1246						.header("X-Aphrodite-Cache", "MISS")
1247						.header("X-Aphrodite-Fill-Pct", {
1248							let v = state.fill_pct.load(Ordering::Relaxed) as f64 / 100.0;
1249							if v.is_finite() { format!("{:.1}", v) } else { "0.0".to_string() }
1250						})
1251						.body(Body::from(body))
1252						.unwrap();
1253				}
1254			}
1255
1256			if state.dev {
1257				let elapsed = t0.elapsed();
1258				let body_preview = if resp_body.len() > 500 {
1259					let s = std::str::from_utf8(&resp_body).unwrap_or("?");
1260					let preview: String = s.char_indices().take_while(|(i, _)| *i < 200).map(|(_, c)| c).collect();
1261					format!("{}... ({} total)", preview, resp_body.len())
1262				} else {
1263					std::str::from_utf8(&resp_body).unwrap_or("?").to_string()
1264				};
1265				tracing::info!(
1266					id = %req_id_short,
1267					status = %status,
1268					resp_len = resp_body.len(),
1269					elapsed_ms = elapsed.as_millis(),
1270					body = %body_preview,
1271					"<<< RES"
1272				);
1273			}
1274			// Use already-extracted content_type (fetched before response.bytes())
1275			state.record_latency(t0.elapsed());
1276			state.record_request(
1277				req_id_short,
1278				method.as_str(),
1279				path.path(),
1280				status.as_u16(),
1281				false,
1282				t0.elapsed().as_millis(),
1283			);
1284			// Store raw response in LLM cache if applicable - success only (F2, see
1285			// the compressed-path cache write above for the full rationale).
1286			if let Some(ck) = cache_key {
1287				if status.is_success() && resp_body.len() <= RESPONSE_CACHE_MAX_BODY_BYTES {
1288					if let Ok(mut cache) = state.response_cache.lock() {
1289						cache.put(ck, (std::time::Instant::now(), resp_body.to_vec()));
1290					}
1291				}
1292			}
1293			let mut builder = Response::builder().status(status);
1294			builder = copy_upstream_headers(builder, &upstream_headers);
1295			builder = builder.header("X-Aphrodite-Cache", "MISS");
1296			builder = builder.header("X-Aphrodite-Fill-Pct", {
1297				let v = state.fill_pct.load(Ordering::Relaxed) as f64 / 100.0;
1298				if v.is_finite() { format!("{:.1}", v) } else { "0.0".to_string() }
1299			});
1300			if let Some(ct) = content_type {
1301				builder = builder.header("Content-Type", ct);
1302			}
1303			builder.body(Body::from(resp_body)).unwrap()
1304		},
1305		Err(e) => {
1306			if final_error_was_timeout {
1307				state.upstream_timeouts.fetch_add(1, Ordering::Relaxed);
1308			} else {
1309				state.upstream_connect_errors.fetch_add(1, Ordering::Relaxed);
1310			}
1311			state.record_latency(t0.elapsed());
1312			state.record_request(req_id_short, method.as_str(), path.path(), 502, false, t0.elapsed().as_millis());
1313			state.record_error(format!("upstream: {}", e));
1314			if state.dev {
1315				tracing::error!(
1316					id = %req_id_short,
1317					error = %e,
1318					elapsed_ms = t0.elapsed().as_millis(),
1319					"<<< ERR"
1320				);
1321			}
1322			// 02-F8: same rule as the body-read error above - `reqwest::Error`'s
1323			// `Display` can embed the upstream URL and, in some TLS/DNS
1324			// failure modes, host/proxy details; a caller shouldn't be able
1325			// to fingerprint `state.api_url` through this response.
1326			(
1327				StatusCode::BAD_GATEWAY,
1328				Json(serde_json::json!({"error": "upstream request failed"})),
1329			)
1330				.into_response()
1331		},
1332	}
1333}
1334
1335/// Detect content type for adaptive compression strategy.
1336fn proxy_detect_content_type(content: &str) -> &'static str {
1337	let first_line = content.lines().next().unwrap_or("");
1338
1339	// Structured output detection
1340	if content.starts_with('{') || content.starts_with('[') {
1341		// Validate JSON before classifying
1342		if serde_json::from_str::<serde_json::Value>(content).is_err() {
1343			// Not valid JSON despite starting with { or [ - treat as text
1344			return "text";
1345		}
1346		if content.contains("exit_code") || content.contains("\"status\"") {
1347			return "tool_output";
1348		}
1349		return "json";
1350	}
1351
1352	// Code detection - language-specific (before broad error check)
1353	if content.lines().count() > 3 {
1354		// Rust - require fn keyword PLUS one of arrow, borrow, or use
1355		// to distinguish from Python/JavaScript that happens to contain "fn "
1356		if content.lines().any(|l| {
1357			let t = l.trim_start();
1358			t.starts_with("fn ")
1359				|| t.starts_with("pub fn ")
1360				|| t.starts_with("async fn ")
1361				|| t.starts_with("pub async fn ")
1362				|| t.starts_with("impl ")
1363				|| t.starts_with("struct ")
1364				|| t.starts_with("pub struct ")
1365				|| t.starts_with("enum ")
1366				|| t.starts_with("pub enum ")
1367		}) && (content.contains("-> ") || content.contains("&") || content.contains("use "))
1368		{
1369			return "code_rust";
1370		}
1371		// Python
1372		if content.contains("def ")
1373			&& (content.contains("import ")
1374				|| content.contains("class ")
1375				|| content.contains("from ")
1376				|| content.contains("self."))
1377		{
1378			return "code_python";
1379		}
1380		// Go
1381		if (content.contains("func ") || content.contains("package ")) && content.contains("import (") {
1382			return "code_go";
1383		}
1384		// JS/TS
1385		if (content.contains("function ") || content.contains("const ") || content.contains("=> "))
1386			&& (content.contains("import ") || content.contains("export "))
1387		{
1388			return "code_js";
1389		}
1390		// Generic code
1391		if content.contains("fn ")
1392			|| content.contains("def ")
1393			|| content.contains("class ")
1394			|| content.contains("import ")
1395			|| content.contains("pub fn")
1396		{
1397			return "code";
1398		}
1399	}
1400
1401	// Aphrodite-side semantic detection runs BEFORE the loose first-line prefix
1402	// heuristics below (which misfire on e.g. a `test result:` summary line -
1403	// classified `build_output` by the `test ` prefix - or grep hits whose text
1404	// happens to contain "error"). The detector is conservative (strong
1405	// line-prefix / marker signals, majority votes) so it only fires on a
1406	// genuine git-status / ls / test / grep / git-log shape, and it keeps the
1407	// proxy path in parity with the hook/FFI path (which runs the same detector).
1408	if let Some(t) = crate::preview::detect_semantic_type(content) {
1409		return t;
1410	}
1411
1412	// Error output - always keep visible
1413	if first_line.contains("error")
1414		|| first_line.contains("Error")
1415		|| first_line.contains("ERROR")
1416		|| first_line.contains("Traceback")
1417		|| first_line.contains("panic")
1418		|| first_line.starts_with("thread '")
1419	{
1420		return "error";
1421	}
1422
1423	// Build/test output patterns
1424	if first_line.starts_with("Compiling ")
1425		|| first_line.starts_with("   Compiling ")
1426		|| first_line.contains("Finished")
1427		|| first_line.starts_with("running ")
1428		|| first_line.starts_with("test ")
1429	{
1430		return "build_output";
1431	}
1432
1433	// Linter output patterns
1434	if first_line.starts_with("error[E")
1435		|| first_line.starts_with("error: ")
1436		|| first_line.starts_with("warning[")
1437		|| first_line.starts_with("warning: ")
1438		|| first_line.contains("|") && (first_line.contains("error") || first_line.contains("warning"))
1439		|| first_line.contains("mypy")
1440		|| first_line.contains("clippy")
1441		|| first_line.contains("eslint")
1442		|| first_line.contains("tsc ")
1443	{
1444		return "linter";
1445	}
1446
1447	// Diff output
1448	if first_line.starts_with("diff --git ")
1449		|| first_line.starts_with("@@ -")
1450		|| first_line.starts_with("+++ ")
1451		|| first_line.starts_with("--- ")
1452	{
1453		return "diff";
1454	}
1455
1456	// Git output
1457	if first_line.starts_with("commit ") || first_line.starts_with("On branch ") {
1458		return "git";
1459	}
1460
1461	// Log output - only if content has explicit log markers
1462	if content.lines().any(|l| {
1463		let t = l.trim();
1464		t.starts_with('[')
1465			&& (t.contains("INFO")
1466				|| t.contains("WARN")
1467				|| t.contains("ERROR")
1468				|| t.contains("DEBUG")
1469				|| t.contains("TRACE")
1470				|| t.contains("FATAL")
1471				|| t.contains("PANIC"))
1472	}) || content.lines().any(|l| {
1473		let t = l.trim();
1474		// Timestamp pattern: ISO-like or syslog-like date at start
1475		t.starts_with(|c: char| c.is_ascii_digit()) && t.len() > 10 && (t.contains(':') || t.contains('-'))
1476	}) {
1477		return "log";
1478	}
1479	"text"
1480}
1481
1482/// Generate structured metadata for CCR markers based on content type.
1483/// Returns pipe-safe key=value pairs (max 200 chars, | escaped to /).
1484fn generate_metadata(content: &str, ct: &str) -> String {
1485	let line_count = content.lines().count();
1486	let mut parts: Vec<String> = Vec::new();
1487
1488	match ct {
1489		"code_rust" => {
1490			parts.push("lang=rs".to_string());
1491			let fns: Vec<&str> = content
1492				.lines()
1493				.filter(|l| {
1494					let t = l.trim_start();
1495					t.starts_with("fn ") || t.starts_with("pub fn ") || t.starts_with("async fn ")
1496				})
1497				.filter_map(|l| {
1498					let t = l.trim_start();
1499					let after_fn = t
1500						.strip_prefix("pub async fn ")
1501						.or_else(|| t.strip_prefix("pub fn "))
1502						.or_else(|| t.strip_prefix("async fn "))
1503						.or_else(|| t.strip_prefix("fn "))?;
1504					after_fn.split(['(', ' ', '<']).next().filter(|s| !s.is_empty())
1505				})
1506				.collect();
1507			if !fns.is_empty() {
1508				parts.push(format!("fns={}", fns.join(",")));
1509			}
1510
1511			let structs: Vec<&str> = content
1512				.lines()
1513				.filter(|l| {
1514					let t = l.trim_start();
1515					t.starts_with("struct ") || t.starts_with("pub struct ")
1516				})
1517				.filter_map(|l| {
1518					let t = l.trim_start();
1519					let after = t
1520						.strip_prefix("pub struct ")
1521						.unwrap_or_else(|| t.strip_prefix("struct ").unwrap_or(t));
1522					after.split(['(', ' ', '<', '{']).next().filter(|s| !s.is_empty())
1523				})
1524				.collect();
1525			if !structs.is_empty() {
1526				parts.push(format!("structs={}", structs.join(",")));
1527			}
1528
1529			// impl blocks: impl TypeName or impl Trait for TypeName
1530			let impls: Vec<&str> = content
1531				.lines()
1532				.filter(|l| {
1533					let t = l.trim_start();
1534					t.starts_with("impl ") || t.starts_with("pub impl ")
1535				})
1536				.filter_map(|l| {
1537					let t = l.trim_start();
1538					let after = t
1539						.strip_prefix("pub impl ")
1540						.unwrap_or_else(|| t.strip_prefix("impl ").unwrap_or(t));
1541					after.split_whitespace().next().map(|w| w.trim_end_matches('<'))
1542				})
1543				.collect();
1544			if !impls.is_empty() {
1545				parts.push(format!("impls={}", impls.join(",")));
1546			}
1547
1548			let traits: Vec<&str> = content
1549				.lines()
1550				.filter(|l| {
1551					let t = l.trim_start();
1552					t.starts_with("trait ") || t.starts_with("pub trait ")
1553				})
1554				.filter_map(|l| {
1555					let t = l.trim_start();
1556					let after = t
1557						.strip_prefix("pub trait ")
1558						.unwrap_or_else(|| t.strip_prefix("trait ").unwrap_or(t));
1559					after.split([' ', '<', '{']).next().filter(|s| !s.is_empty())
1560				})
1561				.collect();
1562			if !traits.is_empty() {
1563				parts.push(format!("traits={}", traits.join(",")));
1564			}
1565
1566			parts.push(format!("ln={}", line_count));
1567		},
1568		"code_python" => {
1569			parts.push("lang=py".to_string());
1570			let fns: Vec<&str> = content
1571				.lines()
1572				.filter(|l| {
1573					let t = l.trim_start();
1574					t.starts_with("def ") || t.starts_with("async def ")
1575				})
1576				.filter_map(|l| {
1577					let t = l.trim_start();
1578					let after = t
1579						.strip_prefix("async def ")
1580						.unwrap_or_else(|| t.strip_prefix("def ").unwrap_or(t));
1581					after.split(['(', ' ', ':']).next().filter(|s| !s.is_empty())
1582				})
1583				.collect();
1584			if !fns.is_empty() {
1585				parts.push(format!("fns={}", fns.join(",")));
1586			}
1587			let classes: Vec<&str> = content
1588				.lines()
1589				.filter(|l| {
1590					let t = l.trim_start();
1591					t.starts_with("class ")
1592				})
1593				.filter_map(|l| {
1594					let t = l.trim_start();
1595					let after = t.strip_prefix("class ")?;
1596					after.split(['(', ' ', ':']).next().filter(|s| !s.is_empty())
1597				})
1598				.collect();
1599			if !classes.is_empty() {
1600				parts.push(format!("classes={}", classes.join(",")));
1601			}
1602			let imports: Vec<&str> = content
1603				.lines()
1604				.filter(|l| {
1605					let t = l.trim_start();
1606					t.starts_with("import ") || t.starts_with("from ")
1607				})
1608				.filter_map(|l| {
1609					let t = l.trim_start();
1610					if let Some(rest) = t.strip_prefix("import ") {
1611						rest.split([' ', ',', ';']).next().filter(|s| !s.is_empty())
1612					} else {
1613						t.strip_prefix("from ")?.split(' ').next().filter(|s| !s.is_empty())
1614					}
1615				})
1616				.collect();
1617			if !imports.is_empty() {
1618				parts.push(format!("imports={}", imports.join(",")));
1619			}
1620			// Decorators: @route, @dataclass, @staticmethod, etc.
1621			let decorators: Vec<&str> = content
1622				.lines()
1623				.filter(|l| {
1624					let t = l.trim_start();
1625					t.starts_with('@')
1626				})
1627				.filter_map(|l| {
1628					let t = l.trim_start();
1629					let name = &t[1..];
1630					name.split(['(', ' ']).next().filter(|s| !s.is_empty())
1631				})
1632				.collect();
1633			if !decorators.is_empty() {
1634				parts.push(format!("decorators={}", decorators.join(",")));
1635			}
1636			parts.push(format!("ln={}", line_count));
1637		},
1638		"code_go" => {
1639			parts.push("lang=go".to_string());
1640			let fns: Vec<&str> = content
1641				.lines()
1642				.filter(|l| {
1643					let t = l.trim_start();
1644					t.starts_with("func ")
1645				})
1646				.filter_map(|l| {
1647					let t = l.trim_start();
1648					let after = t.strip_prefix("func ")?;
1649					after.split(['(', ' ']).next().filter(|s| !s.is_empty())
1650				})
1651				.collect();
1652			if !fns.is_empty() {
1653				parts.push(format!("fns={}", fns.join(",")));
1654			}
1655			parts.push(format!("ln={}", line_count));
1656		},
1657		"code_js" => {
1658			parts.push("lang=js".to_string());
1659			let fns: Vec<&str> = content
1660				.lines()
1661				.filter(|l| {
1662					let t = l.trim_start();
1663					t.starts_with("function ") || t.starts_with("const ")
1664				})
1665				.filter_map(|l| {
1666					let t = l.trim_start();
1667					if let Some(rest) = t.strip_prefix("function ") {
1668						rest.split(['(', ' ']).next().filter(|s| !s.is_empty())
1669					} else {
1670						t.strip_prefix("const ")?
1671							.split([' ', '=', ':'])
1672							.next()
1673							.filter(|s| !s.is_empty())
1674					}
1675				})
1676				.collect();
1677			if !fns.is_empty() {
1678				parts.push(format!("fns={}", fns.join(",")));
1679			}
1680			parts.push(format!("ln={}", line_count));
1681		},
1682		"code" => {
1683			parts.push("lang=gen".to_string());
1684			// Try to extract function-like signatures from unknown code
1685			let sigs: Vec<&str> = content
1686				.lines()
1687				.filter(|l| {
1688					let t = l.trim_start();
1689					t.starts_with("fn ")
1690						|| t.starts_with("def ")
1691						|| t.starts_with("func ")
1692						|| t.starts_with("function ")
1693						|| t.starts_with("class ")
1694						|| t.starts_with("struct ")
1695				})
1696				.filter_map(|l| {
1697					let t = l.trim_start();
1698					let after = t
1699						.strip_prefix("fn ")
1700						.or_else(|| t.strip_prefix("def "))
1701						.or_else(|| t.strip_prefix("func "))
1702						.or_else(|| t.strip_prefix("function "))
1703						.or_else(|| t.strip_prefix("class "))
1704						.or_else(|| t.strip_prefix("struct "))?;
1705					after.split(['(', ' ']).next()
1706				})
1707				.collect();
1708			if !sigs.is_empty() {
1709				parts.push(format!("sigs={}", sigs.join(",")));
1710			}
1711			parts.push(format!("ln={}", line_count));
1712		},
1713		"error" => {
1714			let mut trace = String::new();
1715			for l in content.lines() {
1716				let t = l.trim();
1717				let ext_pos = t.find(".rs:").or_else(|| t.find(".py:")).or_else(|| t.find(".go:"));
1718				if let Some(pos) = ext_pos {
1719					// `pos` itself is a valid boundary (`.find` on ASCII
1720					// patterns), but `start`/`end` are arbitrary byte offsets
1721					// from it and can land mid-codepoint on non-ASCII lines
1722					// (e.g. a CJK comment before ".rs:") - snap both to the
1723					// nearest valid boundary rather than panicking on `t[..]`.
1724					let mut start = pos.saturating_sub(12);
1725					while start > 0 && !t.is_char_boundary(start) {
1726						start -= 1;
1727					}
1728					let mut end = (pos + 40).min(t.len());
1729					while end < t.len() && !t.is_char_boundary(end) {
1730						end += 1;
1731					}
1732					trace = t[start..end].to_string();
1733					break;
1734				}
1735			}
1736			if !trace.is_empty() {
1737				parts.push(format!("trace={}", trace.replace('|', "/")));
1738			}
1739			let msg = content.lines().find(|l| l.contains("Error:") || l.contains("error[")).map(|l| {
1740				let t = l.trim();
1741				let idx = t.find("Error:").or_else(|| t.find("error[")).unwrap_or(0);
1742				t[idx..].chars().take(80).collect::<String>().replace('|', "/")
1743			});
1744			if let Some(m) = msg {
1745				parts.push(format!("msg={}", m));
1746			} else {
1747				let fl = content.lines().next().unwrap_or("").trim();
1748				if !fl.is_empty() {
1749					parts.push(format!("msg={}", fl.chars().take(80).collect::<String>().replace('|', "/")));
1750				}
1751			}
1752			let err_count = content
1753				.lines()
1754				.filter(|l| l.contains("error") || l.starts_with("thread '"))
1755				.count();
1756			if err_count > 0 {
1757				parts.push(format!("N_errors={}", err_count));
1758			}
1759		},
1760		"diff" => {
1761			let files = content.lines().filter(|l| l.starts_with("diff --git ")).count();
1762			if files > 0 {
1763				parts.push(format!("files={}", files));
1764			}
1765			let adds = content.lines().filter(|l| l.starts_with('+') && !l.starts_with("+++")).count();
1766			let dels = content.lines().filter(|l| l.starts_with('-') && !l.starts_with("---")).count();
1767			if adds > 0 {
1768				parts.push(format!("adds={}", adds));
1769			}
1770			if dels > 0 {
1771				parts.push(format!("dels={}", dels));
1772			}
1773		},
1774		"git" => {
1775			for l in content.lines() {
1776				let t = l.trim();
1777				if let Some(rest) = t.strip_prefix("On branch ") {
1778					parts.push(format!("branch={}", rest.trim().replace('|', "/")));
1779					break;
1780				}
1781			}
1782			if !parts.iter().any(|p| p.starts_with("branch=")) {
1783				for l in content.lines() {
1784					let t = l.trim();
1785					if !t.is_empty() && !t.starts_with("* ") && !t.starts_with("  ") {
1786						parts.push(format!("branch={}", t.chars().take(40).collect::<String>().replace('|', "/")));
1787						break;
1788					}
1789				}
1790			}
1791			let commits = content
1792				.lines()
1793				.filter(|l| l.starts_with("commit ") || l.trim().starts_with("* ") || l.contains("commit"))
1794				.count();
1795			if commits > 0 {
1796				parts.push(format!("commits={}", commits));
1797			}
1798		},
1799		"build_output" => {
1800			if content.contains("error") || content.contains("aborting") {
1801				parts.push("status=FAIL".to_string());
1802			} else {
1803				parts.push("status=OK".to_string());
1804			}
1805			let files = content
1806				.lines()
1807				.filter(|l| l.starts_with("Compiling ") || l.contains(" Compiling "))
1808				.count();
1809			if files > 0 {
1810				parts.push(format!("files={}", files));
1811			}
1812			let first_err = content
1813				.lines()
1814				.find(|l| l.contains("error[") || l.contains("Error:"))
1815				.map(|l| l.trim().chars().take(80).collect::<String>().replace('|', "/"));
1816			if let Some(e) = first_err {
1817				parts.push(format!("first_err={}", e));
1818			}
1819		},
1820		"log" => {
1821			for l in content.lines() {
1822				let t = l.trim();
1823				for level in &["ERROR", "WARN", "WARNING", "INFO", "DEBUG", "TRACE", "FATAL", "PANIC"] {
1824					if t.contains(level) {
1825						parts.push(format!("level={}", level.to_lowercase()));
1826						break;
1827					}
1828				}
1829				if parts.iter().any(|p| p.starts_with("level=")) {
1830					break;
1831				}
1832			}
1833			let last_line = content.lines().last().unwrap_or("").trim().chars().take(60).collect::<String>();
1834			if !last_line.is_empty() {
1835				parts.push(format!("last={}", last_line.replace('|', "/")));
1836			}
1837			parts.push(format!("ln={}", line_count));
1838		},
1839		"linter" => {
1840			let files_linted = content
1841				.lines()
1842				.filter(|l| {
1843					(l.contains(".rs:") || l.contains(".py:") || l.contains(".go:") || l.contains(".ts:"))
1844						&& (l.contains("error") || l.contains("warning"))
1845				})
1846				.count();
1847			if files_linted > 0 {
1848				parts.push(format!("files={}", files_linted));
1849			}
1850			let first_err = content
1851				.lines()
1852				.find(|l| l.contains("error[") || l.contains("Error:") || l.starts_with("error: "))
1853				.map(|l| l.trim().chars().take(80).collect::<String>().replace('|', "/"));
1854			if let Some(e) = first_err {
1855				parts.push(format!("first_err={}", e));
1856			}
1857			parts.push(format!("ln={}", line_count));
1858		},
1859		"json" | "tool_output" => {
1860			// Extract unique top-level JSON keys via simple scan
1861			// Look for '"key_name":' patterns without regex
1862			let mut keys: Vec<String> = Vec::new();
1863			for l in content.lines() {
1864				// Find a sequence: '"' + some chars + '":'
1865				let bytes = l.as_bytes();
1866				let mut i = 0;
1867				while i + 3 < bytes.len() {
1868					if bytes[i] == b'"' {
1869						let start = i + 1;
1870						let mut end = start;
1871						while end < bytes.len() && bytes[end] != b'"' {
1872							end += 1;
1873						}
1874						if end < bytes.len() && end + 2 < bytes.len() && bytes[end + 1] == b':' {
1875							let key = &l[start..end];
1876							if !key.starts_with('_') && !keys.contains(&key.to_string()) {
1877								keys.push(key.to_string());
1878								if keys.len() >= 10 {
1879									break;
1880								}
1881							}
1882						}
1883						i = end + 1;
1884					} else {
1885						i += 1;
1886					}
1887				}
1888				if keys.len() >= 10 {
1889					break;
1890				}
1891			}
1892			if !keys.is_empty() {
1893				parts.push(format!("keys={}", keys.join(",")));
1894			}
1895			// Estimate entries count from array-like patterns
1896			let entries = content
1897				.lines()
1898				.filter(|l| {
1899					let t = l.trim();
1900					t.starts_with('{') || t.starts_with('"') || t.starts_with('[')
1901				})
1902				.count();
1903			if entries > 1 {
1904				parts.push(format!("entries={}", entries));
1905			}
1906		},
1907		"text" => {
1908			parts.push(format!("ln={}", line_count));
1909		},
1910		_ => {
1911			parts.push(format!("ln={}", line_count));
1912		},
1913	}
1914
1915	// Build final string: ;-separated key=value pairs (; safe within CCR marker's |
1916	// delimiters). Comma separates list items within values. Max 400 chars
1917	// (coding-tuned: enough for ~25 functions).
1918	let result = parts.join(";").replace('\n', " ").replace('\r', "");
1919	let truncated: String = result.chars().take(400).collect();
1920	truncated.trim_end_matches([';', ' ', ',']).to_string()
1921}
1922
1923// ── CCR output template (editable) ────────────────────────────────
1924/// Edit this function to change the layout the LLM sees when content
1925/// is compressed. Three-line format by default: preview, structure, marker.
1926fn proxy_format_ccr_output(
1927	preview: &str,
1928	ct: &str,
1929	metadata: &str,
1930	center: Option<&str>,
1931	hash: &str,
1932	size: usize,
1933) -> String {
1934	let center_seg = center.map(|c| format!(";center={c}")).unwrap_or_default();
1935	format!("{preview}\n[{ct}: {metadata}{center_seg}]\n<<<CCR:{hash}|{ct}|{size}>>>")
1936}
1937
1938/// Build a smart content-type-aware preview for the CCR output.
1939///
1940/// Returns the most informative excerpt based on content type:
1941/// - Code: first 3 lines (imports + first signature)
1942/// - Error: the actual error line, not the traceback header
1943/// - Diff: first file changed
1944/// - JSON: key count summary
1945/// - Default: first line, ~250 chars
1946fn proxy_build_preview(content: &str, ct: &str) -> String {
1947	// Parity + DEFAULT (report 09 §5): route common semantic shapes through the
1948	// SAME `crate::preview::build_preview` the Hermes hook/FFI path uses, so
1949	// both paths emit an IDENTICAL, self-describing `[type:...]` preview instead
1950	// of drifting. Applies to the newly-enriched shapes (git status, ls, test,
1951	// grep, git log) plus generic buckets the Aphrodite-side detector can
1952	// upgrade. The proxy's own richer per-language arms below stay authoritative
1953	// for code/error/json.
1954	if matches!(
1955		ct,
1956		"git" | "git_status" | "gitlog" | "git_log" | "ls" | "dir" | "test" | "test_output" | "grep" | "log"
1957	) || (matches!(ct, "text" | "terminal") && crate::preview::detect_semantic_type(content).is_some())
1958	{
1959		return crate::preview::build_preview(ct, content);
1960	}
1961	match ct {
1962		"code_rust" | "code_python" | "code_go" | "code_js" | "code_ts" | "code_sh" | "code" => {
1963			// Code: structure-map preview - extract fn/def/class/struct sigs
1964			let mut fns: Vec<&str> = Vec::new();
1965			let mut structs: Vec<&str> = Vec::new();
1966			let mut impls: Vec<&str> = Vec::new();
1967			let mut classes: Vec<&str> = Vec::new();
1968			let mut budget: usize = 280;
1969
1970			for line in content.lines() {
1971				if budget == 0 {
1972					break;
1973				}
1974				let trimmed = line.trim();
1975				if trimmed.is_empty() {
1976					continue;
1977				}
1978
1979				// Rust patterns
1980				if ct == "code_rust" || ct == "code" {
1981					if trimmed.strip_prefix("fn ").is_some() {
1982						let sig: String = trimmed.chars().take(58).collect();
1983						fns.push(trimmed); // store ref, build later
1984						budget = budget.saturating_sub(sig.len() + 2);
1985					} else if trimmed.strip_prefix("pub fn ").is_some() {
1986						let sig: String = trimmed.chars().take(58).collect();
1987						fns.push(trimmed);
1988						budget = budget.saturating_sub(sig.len() + 2);
1989					} else if trimmed.starts_with("struct ") || trimmed.starts_with("pub struct ") {
1990						let s: String = trimmed.chars().take(50).collect();
1991						structs.push(trimmed);
1992						budget = budget.saturating_sub(s.len() + 2);
1993					} else if trimmed.starts_with("impl ") {
1994						let s: String = trimmed.chars().take(50).collect();
1995						impls.push(trimmed);
1996						budget = budget.saturating_sub(s.len() + 2);
1997					}
1998				}
1999				// Python patterns
2000				if ct == "code_python" || ct == "code" {
2001					if (trimmed.starts_with("def ") || trimmed.starts_with("async def ")) && trimmed.ends_with(':') {
2002						let s: String = trimmed.chars().take(58).collect();
2003						fns.push(trimmed);
2004						budget = budget.saturating_sub(s.len() + 2);
2005					} else if trimmed.starts_with("class ") && trimmed.ends_with(':') {
2006						let s: String = trimmed.chars().take(50).collect();
2007						classes.push(trimmed);
2008						budget = budget.saturating_sub(s.len() + 2);
2009					}
2010				}
2011				// Go patterns
2012				if ct == "code_go" && trimmed.starts_with("func ") {
2013					let s: String = trimmed.chars().take(58).collect();
2014					fns.push(trimmed);
2015					budget = budget.saturating_sub(s.len() + 2);
2016				}
2017			}
2018
2019			// Build summary line: [code_rust:3fns|2structs|1impl crate::proxy]
2020			let mut parts: Vec<String> = Vec::new();
2021			if !fns.is_empty() {
2022				parts.push(format!("{}fns", fns.len()));
2023			}
2024			if !structs.is_empty() {
2025				parts.push(format!("{}structs", structs.len()));
2026			}
2027			if !impls.is_empty() {
2028				parts.push(format!("{}impls", impls.len()));
2029			}
2030			if !classes.is_empty() {
2031				parts.push(format!("{}classes", classes.len()));
2032			}
2033			let summary = if parts.is_empty() { "?".to_string() } else { parts.join("|") };
2034
2035			// Show first 2 signatures inline
2036			let sig_previews: Vec<String> =
2037				fns.iter().take(2).map(|s| s.chars().take(56).collect::<String>()).collect();
2038			let sig_str = sig_previews.join("; ");
2039
2040			let lines = content.lines().count();
2041			format!("[{ct}:{summary} {sig_str} {lines}L]").chars().take(300).collect()
2042		},
2043		"error" => {
2044			// Error: find the actual error line, skip traceback noise
2045			let err_line = content
2046				.lines()
2047				.find(|l| l.contains("Error:") || l.contains("error[") || l.contains("panicked"))
2048				.unwrap_or_else(|| content.lines().next().unwrap_or(""));
2049			err_line.chars().take(300).collect()
2050		},
2051		"diff" => {
2052			// Diff: show which files changed
2053			let files: Vec<&str> = content.lines().filter(|l| l.starts_with("diff --git ")).take(2).collect();
2054			if files.is_empty() {
2055				content.lines().next().unwrap_or("").chars().take(200).collect()
2056			} else {
2057				files.join("\n").chars().take(300).collect()
2058			}
2059		},
2060		"json" | "tool_output" => {
2061			// JSON: first line + key count
2062			let first = content.lines().next().unwrap_or("");
2063			let key_count = content.matches("\":").count();
2064			format!("{} … {} keys", first.chars().take(150).collect::<String>(), key_count)
2065		},
2066		"build_output" => {
2067			// Build: show status line
2068			content
2069				.lines()
2070				.find(|l| l.contains("Compiling") || l.contains("Finished") || l.contains("error"))
2071				.unwrap_or_else(|| content.lines().next().unwrap_or(""))
2072				.chars()
2073				.take(250)
2074				.collect()
2075		},
2076		_ => {
2077			// Default: first line, ~250 chars
2078			content.lines().next().unwrap_or("").chars().take(250).collect()
2079		},
2080	}
2081}
2082
2083/// Create a CCR marker with preview and structure for the LLM.
2084///
2085/// Uses [`format_ccr_output`] for the output layout. The LLM reads the
2086/// preview + structure first, then decides whether to call
2087/// aphrodite_retrieve for the full content.
2088fn smart_marker(hash: &str, content: &str, ct: &str, center: Option<&str>) -> String {
2089	let size = content.len();
2090	let metadata = generate_metadata(content, ct);
2091	let preview = proxy_build_preview(content, ct);
2092	proxy_format_ccr_output(&preview, ct, &metadata, center, hash, size)
2093}
2094
2095/// Cache-mode CCR output - preview + marker, same template.
2096fn cache_marker(hash: &str, content: &str, ct: &str, center: Option<&str>) -> String {
2097	let size = content.len();
2098	let preview: String = content.chars().take(512).collect();
2099	proxy_format_ccr_output(&preview, ct, "", center, hash, size)
2100}
2101
2102/// Compress a Chat Completions API response with smart markers.
2103async fn compress_chat_completion(
2104	state: &AppState,
2105	resp_body: &[u8],
2106	headroom_budget: Option<&str>,
2107) -> Option<serde_json::Value> {
2108	let mut response: serde_json::Value = serde_json::from_slice(resp_body).ok()?;
2109	let choices = response.get_mut("choices")?.as_array_mut()?;
2110	let base_threshold = state.compress_threshold(); // floor threshold for all types
2111
2112	// Headroom budget: lower values compress more aggressively.
2113	// Coding-tuned: smooth linear curve from 0.50 (empty) to 1.0 (full).
2114	// Never below 0.5× - semantics and tool chains are worth the tokens.
2115	let budget_mult = headroom_budget
2116		.and_then(|b| {
2117			let val: f64 = b.parse().ok()?;
2118			// Linear interpolation: 0.50 + (fill% * 0.50), clamped [0.50, 1.0]
2119			Some((0.50 + (val / 100.0) * 0.50).clamp(0.50, 1.0))
2120		})
2121		.unwrap_or(1.0);
2122	let mut did_compress = false;
2123
2124	for choice in choices {
2125		let message = choice.get_mut("message")?;
2126
2127		// Compress text content with smart markers
2128		if let Some(content_val) = message.get_mut("content") {
2129			if let Some(content) = content_val.as_str() {
2130				let ct = proxy_detect_content_type(content);
2131				let threshold = (state.threshold_for(ct).max(base_threshold) as f64 * budget_mult) as usize;
2132				if content.len() > threshold {
2133					if let Some(ccr) = &state.ccr {
2134						let hash = compute_key(content.as_bytes());
2135						// F4: only replace `content` with a marker if the content is
2136						// actually retrievable under `hash` - either it was already
2137						// there (cache hit) or this `put` succeeded. A failed put
2138						// (store full/locked/panicked) must NOT be followed by
2139						// swapping the response for an unresolvable marker - that
2140						// would permanently destroy content that never reached the
2141						// client any other way.
2142						let stored = if ccr_get(ccr, &hash).await.is_some() {
2143							state.ccr_hits.fetch_add(1, Ordering::Relaxed);
2144							true
2145						} else {
2146							state.ccr_misses.fetch_add(1, Ordering::Relaxed);
2147							let ok = ccr_put(ccr, &hash, content).await;
2148							if ok {
2149								state.ccr_created.fetch_add(1, Ordering::Relaxed);
2150							} else {
2151								tracing::error!(hash = %hash, "ccr_put failed - leaving content uncompressed to avoid data loss");
2152							}
2153							ok
2154						};
2155						if stored {
2156							let (compressed, orig_len) = {
2157								let compressed = match state.mode {
2158									ProxyMode::Cache => cache_marker(&hash, content, ct, None),
2159									ProxyMode::Token => smart_marker(&hash, content, ct, None),
2160								};
2161								let len = content.len();
2162								state.record_compression(ct);
2163								(compressed, len)
2164							};
2165							let marker_len = compressed.len();
2166							// Savings = bytes actually removed from the response
2167							// (original content minus the rendered marker that
2168							// replaces it), not the bare hash length - the marker
2169							// is hundreds of chars longer than the 40-char hash,
2170							// so subtracting only `hash.len()` overstated savings
2171							// (report 05 F5). Unit is bytes throughout - see
2172							// `tokens_saved`'s field doc for the naming caveat.
2173							state
2174								.tokens_saved
2175								.fetch_add(orig_len.saturating_sub(marker_len) as u64, Ordering::Relaxed);
2176							*content_val = serde_json::Value::String(compressed);
2177							did_compress = true;
2178							state.update_compression_ratio(orig_len, marker_len);
2179						}
2180					}
2181				} else if content.len() > state.inline_ccr_threshold() {
2182					// Below compression threshold but above inline threshold: store in inline_ccr
2183					// so later retrievals can find tiny entries without a backend round-trip.
2184					let hash = compute_key(content.as_bytes());
2185					if let Ok(mut map) = state.inline_ccr.lock() {
2186						if map.contains(&hash) {
2187							state.inline_ccr_hits.fetch_add(1, Ordering::Relaxed);
2188						} else {
2189							state.inline_ccr_misses.fetch_add(1, Ordering::Relaxed);
2190							map.put(hash, content.to_string());
2191						}
2192					}
2193				}
2194			}
2195		}
2196
2197		// 02-F3: `tool_calls[].function.arguments` is JSON the CLIENT feeds
2198		// straight into its own tool executor - it is not model-facing prose
2199		// the model could choose to `aphrodite_retrieve`. A prior version of
2200		// this function compressed it into a CCR marker like any other
2201		// content, which meant the client tried to execute a tool call with
2202		// `arguments = "<<<CCR:...>>>"` - unparseable JSON that breaks every
2203		// real OpenAI-tools client. Tool call arguments now always pass
2204		// through untouched; only `message.content` (model-facing text,
2205		// handled above) is a legitimate compression target here.
2206	}
2207
2208	if did_compress { Some(response) } else { None }
2209}
2210
2211// ── Tool relay handler ───────────────────────────────────────────────
2212
2213/// `POST /tool_relay` - dispatches a Hermes-side tool call (see
2214/// [`execute_tool_relay`]). Runs synchronously unless the request carries
2215/// an `https://` `callback_url`, in which case it's spawned onto
2216/// `task_tracker` and the result is POSTed back later instead of returned
2217/// inline.
2218pub async fn handle_tool_relay(
2219	State(state): State<Arc<AppState>>,
2220	Json(req): Json<ToolRelayRequest>,
2221) -> impl IntoResponse {
2222	state.tool_relay_calls.fetch_add(1, Ordering::Relaxed);
2223	tracing::info!(tool = %req.tool, "tool_relay");
2224
2225	// Validate aphrodite_retrieve: requests with only `query` and no `hash` are
2226	// invalid and must return 400 BAD_REQUEST instead of silently passing through.
2227	if req.tool == "aphrodite_retrieve" && req.params.get("hash").and_then(|v| v.as_str()).is_none() {
2228		return (
2229			StatusCode::BAD_REQUEST,
2230			Json(ToolRelayResponse {
2231				success: false,
2232				result: None,
2233				error: Some(
2234					"`hash` is required for 💋/aphrodite_retrieve. Requests with only `query` and no `hash` are \
2235					 invalid."
2236						.into(),
2237				),
2238				async_call: false,
2239			}),
2240		)
2241			.into_response();
2242	}
2243
2244	if let Some(cb) = &req.callback_url {
2245		// SSRF protection: only https:// URLs allowed
2246		let parsed_url = match url::Url::parse(cb) {
2247			Ok(u) if u.scheme() == "https" => u,
2248			_ => {
2249				// F13: report the rejection honestly instead of `success:true`
2250				// with nothing executed - a caller passing e.g. a loopback
2251				// `http://` callback previously got told it worked and then
2252				// waited forever for a callback that would never arrive.
2253				tracing::warn!(callback_url = %cb, "tool_relay callback rejected: only https scheme allowed");
2254				return (
2255					StatusCode::BAD_REQUEST,
2256					Json(ToolRelayResponse {
2257						success: false,
2258						result: None,
2259						error: Some("callback_url must use the https scheme".into()),
2260						async_call: false,
2261					}),
2262				)
2263					.into_response();
2264			},
2265		};
2266		let tracker = state.task_tracker.clone();
2267		let state = state.clone();
2268		let tool = req.tool.clone();
2269		let params = req.params.clone();
2270		let cb = parsed_url.to_string();
2271		tracker.spawn(async move {
2272			let result = execute_tool_relay(&state, &tool, &params).await;
2273			// F13: the execute result itself (success/failure of the tool
2274			// call) was never counted on this async path - only the
2275			// synchronous path below incremented these, so
2276			// `tool_relay_success + tool_relay_failure` silently diverged
2277			// from `tool_relay_calls` for every async callback request.
2278			if result.is_ok() {
2279				state.tool_relay_success.fetch_add(1, Ordering::Relaxed);
2280			} else {
2281				state.tool_relay_failure.fetch_add(1, Ordering::Relaxed);
2282			}
2283			let _ = state
2284				.client
2285				.post(&cb)
2286				.json(&result)
2287				.timeout(Duration::from_secs(5))
2288				.send()
2289				.await;
2290		});
2291		return Json(ToolRelayResponse { success: true, result: None, error: None, async_call: true }).into_response();
2292	}
2293
2294	match execute_tool_relay(&state, &req.tool, &req.params).await {
2295		Ok(val) => {
2296			state.tool_relay_success.fetch_add(1, Ordering::Relaxed);
2297			Json(ToolRelayResponse { success: true, result: Some(val), error: None, async_call: false }).into_response()
2298		},
2299		Err(e) => {
2300			state.tool_relay_failure.fetch_add(1, Ordering::Relaxed);
2301			Json(ToolRelayResponse { success: false, result: None, error: Some(e), async_call: false }).into_response()
2302		},
2303	}
2304}
2305
2306/// Dispatch a single tool-relay call by name (`aphrodite_retrieve`,
2307/// `aphrodite_compress`, `aphrodite_list`) - the actual work behind
2308/// `POST /tool_relay`, called from [`handle_tool_relay`].
2309async fn execute_tool_relay(
2310	state: &AppState,
2311	tool: &str,
2312	params: &serde_json::Value,
2313) -> Result<serde_json::Value, String> {
2314	match tool {
2315		"aphrodite_retrieve" => {
2316			let hash_raw = params.get("hash").and_then(|v| v.as_str()).ok_or("missing hash")?;
2317			// Strip a `|type|size` marker-body suffix and surrounding
2318			// whitespace (report 05 F3) - an LLM sometimes echoes the full
2319			// marker body back as the hash argument instead of the bare
2320			// hash, and the lookups below (inline_ccr, CCR backend) are
2321			// exact-match only.
2322			let hash = crate::marker::normalize_hash(hash_raw);
2323			// Check inline_ccr first (no round-trip needed for tiny entries)
2324			if let Ok(mut map) = state.inline_ccr.lock() {
2325				if let Some(content) = map.get(hash) {
2326					state.inline_ccr_hits.fetch_add(1, Ordering::Relaxed);
2327					return Ok(serde_json::json!({"found": true, "content": content.clone()}));
2328				}
2329			}
2330			state.inline_ccr_misses.fetch_add(1, Ordering::Relaxed);
2331			// Fallback to CCR store
2332			if let Some(ccr) = &state.ccr {
2333				match ccr_get(ccr, hash).await {
2334					Some(content) => Ok(serde_json::json!({"found": true, "content": content})),
2335					None => Ok(serde_json::json!({"found": false})),
2336				}
2337			} else {
2338				Err("CCR not enabled".into())
2339			}
2340		},
2341		"aphrodite_compress" => {
2342			let content = params.get("content").and_then(|v| v.as_str()).ok_or("missing content")?;
2343			let center = params.get("_ccr_center").and_then(|v| v.as_str());
2344			let hash = compute_key(content.as_bytes());
2345			let size = content.len();
2346			if size < state.inline_ccr_threshold() {
2347				// Tiny content: store inline for the fast path (no CCR backend
2348				// round-trip), AND to the durable backend when one is
2349				// configured (report 06 F5) - the inline map is a 1024-entry
2350				// process-memory LRU, so a busy session can evict this entry
2351				// within minutes even though the marker handed back looks
2352				// exactly like a durable one; without the durable copy,
2353				// `aphrodite_retrieve` after eviction (or a proxy restart)
2354				// returns `{"found": false}` for a marker the model was told
2355				// is resolvable. Best-effort: a failed durable put doesn't
2356				// fail this call since the inline copy still serves reads
2357				// until it's evicted.
2358				if let Ok(mut map) = state.inline_ccr.lock() {
2359					if map.contains(&hash) {
2360						state.inline_ccr_hits.fetch_add(1, Ordering::Relaxed);
2361					} else {
2362						state.inline_ccr_misses.fetch_add(1, Ordering::Relaxed);
2363						map.put(hash.clone(), content.to_string());
2364					}
2365				}
2366				if let Some(ccr) = &state.ccr {
2367					ccr_put(ccr, &hash, content).await;
2368				}
2369				Ok(serde_json::json!({
2370					"compressed": smart_marker(&hash, content, "compress", center),
2371					"hash": hash,
2372					"original_size": size
2373				}))
2374			} else if let Some(ccr) = &state.ccr {
2375				// F4: don't hand back a marker for content that failed to store.
2376				if !ccr_put(ccr, &hash, content).await {
2377					return Err("failed to store content in CCR backend".into());
2378				}
2379				let compressed = smart_marker(&hash, content, "compress", center);
2380				// Savings = bytes removed by the marker replacement, not the
2381				// bare hash length (report 05 F5).
2382				state
2383					.tokens_saved
2384					.fetch_add(size.saturating_sub(compressed.len()) as u64, Ordering::Relaxed);
2385				Ok(serde_json::json!({
2386					"compressed": compressed,
2387					"hash": hash,
2388					"original_size": size
2389				}))
2390			} else {
2391				Err("CCR not enabled".into())
2392			}
2393		},
2394		"aphrodite_list" => {
2395			let entries = match &state.ccr {
2396				Some(ccr) => ccr_len(ccr).await,
2397				None => 0,
2398			};
2399			Ok(serde_json::json!({
2400				"entries": entries,
2401				"backend": match state.mode {
2402					ProxyMode::Cache => "in_memory",
2403					ProxyMode::Token => "sqlite",
2404				},
2405			}))
2406		},
2407		_ => Err(format!("Unknown tool: {}", tool)),
2408	}
2409}
2410
2411// ── Programmatic CCR handlers ────────────────────────────────────────
2412
2413/// `POST /ccr/create` - stores content directly into the CCR backend,
2414/// bypassing the Chat Completions compression path. Accepts either a JSON
2415/// [`CcrCreateRequest`] body or a raw octet-stream (treated as the content
2416/// itself, hashed for the key). Fires the `notify_url` webhook on success
2417/// if configured.
2418pub async fn handle_ccr_create(
2419	State(state): State<Arc<AppState>>,
2420	headers: axum::http::HeaderMap,
2421	body: Bytes,
2422) -> impl IntoResponse {
2423	let content_type = headers.get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("");
2424
2425	// Support both JSON and raw octet-stream bodies
2426	if content_type.contains("json") {
2427		// Parse as JSON CcrCreateRequest
2428		match serde_json::from_slice::<CcrCreateRequest>(&body) {
2429			Ok(req) => {
2430				let original_size = req.content.len();
2431				let hash = req.key.unwrap_or_else(|| compute_key(req.content.as_bytes()));
2432
2433				// F14: report unavailability instead of a fabricated success -
2434				// previously this endpoint returned a hash + savings ratio even
2435				// when `state.ccr` was `None` (e.g. token mode with
2436				// `--no-ccr-marker`), so nothing was ever stored and every
2437				// later `/retrieve` of that hash 404s. Mirrors
2438				// `handle_ccr_delete`'s existing `None` branch.
2439				let ccr = match &state.ccr {
2440					Some(ccr) => ccr,
2441					None => {
2442						return (
2443							StatusCode::SERVICE_UNAVAILABLE,
2444							Json(serde_json::json!({"error": "CCR not enabled"})),
2445						)
2446							.into_response();
2447					},
2448				};
2449				if !ccr_put(ccr, &hash, &req.content).await {
2450					return (
2451						StatusCode::INTERNAL_SERVER_ERROR,
2452						Json(serde_json::json!({"error": "failed to store content in CCR backend"})),
2453					)
2454						.into_response();
2455				}
2456				state.ccr_created.fetch_add(1, Ordering::Relaxed);
2457				// Unlike the chat-completion/tool-relay paths, this
2458				// endpoint's documented wire contract IS the bare hash
2459				// (see `compressed_size`/`marker_size` below and
2460				// `test_ccr_create_response_serde_shape`) - no marker is
2461				// rendered here, so `hash.len()` is the correct
2462				// subtractee, not an approximation (report 05 F5).
2463				state
2464					.tokens_saved
2465					.fetch_add(original_size.saturating_sub(hash.len()) as u64, Ordering::Relaxed);
2466				state.requests_compressed.fetch_add(1, Ordering::Relaxed);
2467
2468				// Update the compression ratio EMA so that /stats
2469				// reflects the compressibility of content flowing
2470				// through /ccr/create.
2471				//
2472				// Use a byte-entropy based estimate for the effective
2473				// compressed size, rather than the rendered marker
2474				// length (which is ~constant regardless of content
2475				// compressibility).  The estimate models a simplified
2476				// dictionary compressor: we count unique 3-byte
2477				// trigrams in the first 4096 bytes and use that count
2478				// (raised gently) as the compressed payout.  Highly
2479				// repetitive content (few unique trigrams) yields a
2480				// high ratio; random-looking content yields a low
2481				// ratio.
2482				let estimate = estimate_compressed_size(&req.content);
2483				state.update_compression_ratio(original_size, estimate);
2484
2485				if let Some(notify_url) = &state.notify_url {
2486					let notification = CcrNotification {
2487						event: "ccr_created".into(),
2488						hash: hash.clone(),
2489						created_at: std::time::SystemTime::now()
2490							.duration_since(std::time::UNIX_EPOCH)
2491							.unwrap_or_default()
2492							.as_secs(),
2493						ttl: req.ttl_seconds.unwrap_or(3600),
2494						tags: req.tags.unwrap_or_default(),
2495					};
2496					let tracker = state.task_tracker.clone();
2497					let client = state.client.clone();
2498					let url = notify_url.clone();
2499					let key = state.notify_key.clone();
2500					let state_clone = state.clone();
2501					tracker.spawn(async move {
2502						let mut req = client.post(&url).json(&notification);
2503						if let Some(k) = &key {
2504							req = req.header("Authorization", format!("Bearer {k}"));
2505						}
2506						match req.timeout(Duration::from_secs(5)).send().await {
2507							Ok(r) if r.status().is_success() => {
2508								state_clone.notify_success.fetch_add(1, Ordering::Relaxed);
2509							},
2510							_ => {
2511								state_clone.notify_failure.fetch_add(1, Ordering::Relaxed);
2512							},
2513						}
2514					});
2515				}
2516
2517				let compressed_size = hash.len();
2518				Json(CcrCreateResponse {
2519					hash,
2520					token_savings_ratio: if original_size > 0 {
2521						original_size as f64 / compressed_size.max(1) as f64
2522					} else {
2523						1.0
2524					},
2525					original_size,
2526					compressed_size,
2527					marker_size: compressed_size,
2528				})
2529				.into_response()
2530			},
2531			Err(e) => (
2532				StatusCode::BAD_REQUEST,
2533				Json(serde_json::json!({"error": format!("invalid JSON: {}", e)})),
2534			)
2535				.into_response(),
2536		}
2537	} else {
2538		// Treat raw body as content directly
2539		let content = match String::from_utf8(body.to_vec()) {
2540			Ok(c) => c,
2541			Err(_) => {
2542				return (
2543					StatusCode::BAD_REQUEST,
2544					Json(serde_json::json!({"error": "invalid UTF-8 in body"})),
2545				)
2546					.into_response();
2547			},
2548		};
2549		let original_size = content.len();
2550		let hash = compute_key(content.as_bytes());
2551
2552		// F14/F4: same rules as the JSON-body branch above - 503 when CCR
2553		// isn't enabled, 500 (not a fabricated success) when the store write
2554		// itself fails.
2555		let ccr = match &state.ccr {
2556			Some(ccr) => ccr,
2557			None => {
2558				return (
2559					StatusCode::SERVICE_UNAVAILABLE,
2560					Json(serde_json::json!({"error": "CCR not enabled"})),
2561				)
2562					.into_response();
2563			},
2564		};
2565		if !ccr_put(ccr, &hash, &content).await {
2566			return (
2567				StatusCode::INTERNAL_SERVER_ERROR,
2568				Json(serde_json::json!({"error": "failed to store content in CCR backend"})),
2569			)
2570				.into_response();
2571		}
2572		state.ccr_created.fetch_add(1, Ordering::Relaxed);
2573		state.requests_compressed.fetch_add(1, Ordering::Relaxed);
2574		// See the JSON-body branch above: this endpoint's wire contract
2575		// IS the bare hash, so `hash.len()` is the correct subtractee.
2576		state
2577			.tokens_saved
2578			.fetch_add(original_size.saturating_sub(hash.len()) as u64, Ordering::Relaxed);
2579
2580		let compressed_size = hash.len();
2581		Json(CcrCreateResponse {
2582			hash,
2583			token_savings_ratio: if original_size > 0 {
2584				original_size as f64 / compressed_size.max(1) as f64
2585			} else {
2586				1.0
2587			},
2588			original_size,
2589			compressed_size,
2590			marker_size: compressed_size,
2591		})
2592		.into_response()
2593	}
2594}
2595
2596/// `GET /ccr/list` - reports entry count and backend kind for the active
2597/// CCR store (no listing of actual entries/hashes).
2598pub async fn handle_ccr_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
2599	match &state.ccr {
2600		Some(ccr) => {
2601			let entries = ccr_len(ccr).await;
2602			Json(serde_json::json!({
2603				"entries": entries,
2604				"backend": match state.mode {
2605					ProxyMode::Cache => "in_memory",
2606					ProxyMode::Token => "sqlite",
2607				},
2608				"mode": match state.mode {
2609					ProxyMode::Cache => "cache",
2610					ProxyMode::Token => "token",
2611				},
2612			}))
2613		},
2614		None => Json(serde_json::json!({"entries": 0, "message": "CCR not enabled"})),
2615	}
2616}
2617
2618/// `DELETE /ccr/:hash` - removes a single entry from the CCR backend.
2619/// Returns 404 if the hash wasn't present, 503 if no backend is configured.
2620pub async fn handle_ccr_delete(
2621	State(state): State<Arc<AppState>>,
2622	axum::extract::Path(hash): axum::extract::Path<String>,
2623) -> impl IntoResponse {
2624	match &state.ccr {
2625		Some(ccr) => {
2626			let existed = ccr_del(ccr, &hash).await;
2627			if existed {
2628				(StatusCode::OK, Json(serde_json::json!({"deleted": true, "hash": hash})))
2629			} else {
2630				(
2631					StatusCode::NOT_FOUND,
2632					Json(serde_json::json!({"deleted": false, "hash": hash, "error": "not found"})),
2633				)
2634			}
2635		},
2636		None => (
2637			StatusCode::SERVICE_UNAVAILABLE,
2638			Json(serde_json::json!({"error": "CCR not enabled"})),
2639		),
2640	}
2641}
2642
2643// ── Config reload ──────────────────────────────────────────────────
2644
2645/// Hot-reload aphrodite.toml and apply compression config changes.
2646/// POST /reload - returns the newly loaded compression settings.
2647/// `POST /reload` - re-parse `aphrodite.toml` and apply its `[compression]`
2648/// thresholds to THIS listener's live `AppState` (report 07 F2/F4/T15) -
2649/// previously this endpoint parsed the file, echoed the values back, and
2650/// discarded them; a 200 response with `"reloaded": true` asserted a state
2651/// change that never happened. Other `[compression]` keys
2652/// (`engine_threshold_pct`, `catalog_mode`, `auto_expand*`) have no consumer
2653/// in this crate's proxy path (they're echoed for visibility, not applied -
2654/// see report 07 F8/F9 for their fate).
2655pub async fn handle_ccr_reload(State(state): State<Arc<AppState>>) -> impl IntoResponse {
2656	let config_path = std::env::var("APHRODITE_CONFIG_PATH").unwrap_or_else(|_| "aphrodite.toml".to_string());
2657	match crate::config::MultiConfig::load(&config_path) {
2658		Ok(config) => {
2659			let comp = config.compression.as_ref();
2660			let thresholds = resolve_thresholds(comp);
2661			state.cache_compress_threshold.store(thresholds.cache, Ordering::Relaxed);
2662			state.token_compress_threshold.store(thresholds.token, Ordering::Relaxed);
2663			state.inline_ccr_threshold.store(thresholds.inline, Ordering::Relaxed);
2664			state
2665				.code_multiplier_x100
2666				.store((thresholds.code_multiplier * 100.0) as u64, Ordering::Relaxed);
2667			let body = serde_json::json!({
2668				"reloaded": true,
2669				"applied": true,
2670				"config": config_path,
2671				"compression": {
2672					"tool_threshold_cache": thresholds.cache,
2673					"tool_threshold_token": thresholds.token,
2674					"inline_threshold": thresholds.inline,
2675					"code_multiplier": thresholds.code_multiplier,
2676				},
2677				// Parsed and visible, but not applied by this proxy (see doc
2678				// comment above).
2679				"parsed_only": {
2680					"auto_expand": comp.and_then(|c| c.auto_expand),
2681					"auto_expand_limit": comp.and_then(|c| c.auto_expand_limit),
2682					"terminal_threshold": comp.and_then(|c| c.terminal_threshold),
2683					"engine_threshold_pct": comp.and_then(|c| c.engine_threshold_pct),
2684					"catalog_mode": comp.and_then(|c| c.catalog_mode.clone()),
2685				}
2686			});
2687			tracing::info!(
2688				%config_path,
2689				cache_threshold = thresholds.cache,
2690				token_threshold = thresholds.token,
2691				inline_threshold = thresholds.inline,
2692				code_multiplier = thresholds.code_multiplier,
2693				"config reloaded - compression thresholds applied"
2694			);
2695			(StatusCode::OK, Json(body)).into_response()
2696		},
2697		Err(e) => (
2698			StatusCode::INTERNAL_SERVER_ERROR,
2699			Json(serde_json::json!({"error": format!("failed to reload: {e}")})),
2700		)
2701			.into_response(),
2702	}
2703}
2704
2705// ── Health check ────────────────────────────────────────────────────
2706
2707/// `GET /health` - local-only liveness check; does not call the upstream
2708/// API (see `/health/upstream` for that). Always returns 200 - capability
2709/// state (e.g. whether CCR is enabled) is conveyed via the JSON body
2710/// instead of the status code, since CCR is optional/opt-in.
2711pub async fn health_check(State(state): State<Arc<AppState>>) -> impl IntoResponse {
2712	let ccr_ok = state.ccr.is_some();
2713
2714	(
2715		StatusCode::OK,
2716		Json(serde_json::json!({
2717			"status": "healthy",
2718			"ccr": ccr_ok,
2719			"mode": match state.mode {
2720				ProxyMode::Cache => "cache",
2721				ProxyMode::Token => "token",
2722			},
2723			"version": env!("CARGO_PKG_VERSION"),
2724			"fill_pct": state.fill_pct.load(Ordering::Relaxed) as f64 / 100.0,
2725		})),
2726	)
2727		.into_response()
2728}
2729
2730// ── Tests ────────────────────────────────────────────────────────────
2731
2732// `pub(crate)` (not private) so other in-crate test modules - e.g.
2733// `retrieve::tests` - can reach `test_state_with_ccr()` without duplicating
2734// `AppState`'s ~47-field literal (report 05 T5 verification).
2735#[cfg(test)]
2736pub(crate) mod tests {
2737	use super::*;
2738
2739	#[test]
2740	fn test_compress_threshold_cache() {
2741		use std::{collections::HashMap, sync::Mutex};
2742		let state = AppState {
2743			client: HttpClient::new(),
2744			stream_client: HttpClient::new(),
2745			api_url: "https://upstream-openai.com".into(),
2746			model: "test".into(),
2747			api_key: "test".into(),
2748			ccr: None,
2749			add_markers: false,
2750			mode: ProxyMode::Cache,
2751			tool_relay: false,
2752			notify_url: None,
2753			notify_key: None,
2754			dev: false,
2755			requests_total: AtomicU64::new(0),
2756			requests_compressed: AtomicU64::new(0),
2757			tokens_saved: AtomicU64::new(0),
2758			ccr_hits: AtomicU64::new(0),
2759			ccr_misses: AtomicU64::new(0),
2760			ccr_created: AtomicU64::new(0),
2761			tool_relay_calls: AtomicU64::new(0),
2762			compression_ratio_ema: AtomicU64::new(200), // initial: 2.0x - conservative, avoids startup scale-up
2763			request_history: Mutex::new(VecDeque::new()),
2764			inline_ccr: Mutex::new(lru::LruCache::new(NonZeroUsize::new(1024).unwrap())),
2765			latency_buckets: [
2766				AtomicU64::new(0),
2767				AtomicU64::new(0),
2768				AtomicU64::new(0),
2769				AtomicU64::new(0),
2770				AtomicU64::new(0),
2771			],
2772			total_latency_micros: AtomicU64::new(0),
2773			last_errors: Mutex::new(VecDeque::new()),
2774			compressions_by_type: Mutex::new(HashMap::new()),
2775			response_cache: Mutex::new(lru::LruCache::new(NonZeroUsize::new(128).unwrap())),
2776			response_cache_ttl: std::time::Duration::from_secs(3600),
2777			cache_hits: AtomicU64::new(0),
2778			cache_misses: AtomicU64::new(0),
2779			fill_pct: AtomicU64::new(9000),
2780			task_tracker: TaskTracker::new(),
2781			inline_ccr_hits: AtomicU64::new(0),
2782			inline_ccr_misses: AtomicU64::new(0),
2783			tool_relay_success: AtomicU64::new(0),
2784			tool_relay_failure: AtomicU64::new(0),
2785			notify_success: AtomicU64::new(0),
2786			notify_failure: AtomicU64::new(0),
2787			upstream_errors_4xx: AtomicU64::new(0),
2788			upstream_errors_5xx: AtomicU64::new(0),
2789			upstream_timeouts: AtomicU64::new(0),
2790			upstream_connect_errors: AtomicU64::new(0),
2791			sse_stream_errors: AtomicU64::new(0),
2792			ccr_store_entries: AtomicU64::new(0),
2793			ccr_store_bytes: AtomicU64::new(0),
2794			request_body_bytes: AtomicU64::new(0),
2795			response_body_bytes: AtomicU64::new(0),
2796			upstream_latency_micros: AtomicU64::new(0),
2797			upstream_health_cache: std::sync::Mutex::new(None),
2798			cache_compress_threshold: AtomicUsize::new(CACHE_COMPRESS_THRESHOLD),
2799			token_compress_threshold: AtomicUsize::new(TOKEN_COMPRESS_THRESHOLD),
2800			inline_ccr_threshold: AtomicUsize::new(INLINE_CCR_THRESHOLD),
2801			code_multiplier_x100: AtomicU64::new(300),
2802		};
2803		assert_eq!(state.compress_threshold(), CACHE_COMPRESS_THRESHOLD);
2804	}
2805
2806	#[test]
2807	fn test_compress_threshold_aphrodite() {
2808		let state = AppState { mode: ProxyMode::Token, ..test_state() };
2809		assert_eq!(state.compress_threshold(), TOKEN_COMPRESS_THRESHOLD);
2810	}
2811
2812	// ── T15 (F2): TOML `[compression]` thresholds must actually be honored,
2813	// not silently discarded in favor of the compiled-in consts. ──
2814	#[test]
2815	fn test_resolve_thresholds_toml_overrides_defaults() {
2816		let comp = CompressionConfig {
2817			engine_threshold_pct: None,
2818			engine_protect_first: None,
2819			engine_protect_last: None,
2820			engine_min_msgs: None,
2821			tool_threshold_token: Some(512),
2822			tool_threshold_cache: Some(4096),
2823			terminal_threshold: None,
2824			inline_threshold: Some(2048),
2825			auto_expand: None,
2826			auto_expand_limit: None,
2827			catalog_mode: None,
2828			classifier_poll: None,
2829			code_multiplier: Some(5.0),
2830		};
2831		let t = resolve_thresholds(Some(&comp));
2832		assert_eq!(t.cache, 4096);
2833		assert_eq!(t.token, 512);
2834		assert_eq!(t.inline, 2048);
2835		assert_eq!(t.code_multiplier, 5.0);
2836	}
2837
2838	#[test]
2839	fn test_resolve_thresholds_defaults_when_no_toml() {
2840		let t = resolve_thresholds(None);
2841		assert_eq!(t.cache, CACHE_COMPRESS_THRESHOLD);
2842		assert_eq!(t.token, TOKEN_COMPRESS_THRESHOLD);
2843		assert_eq!(t.inline, INLINE_CCR_THRESHOLD);
2844		// F10: the corrected default - 2 only ever existed because
2845		// "3.0".parse::<usize>() silently failed.
2846		assert_eq!(t.code_multiplier, 3.0);
2847	}
2848
2849	// ── T15 (F4): `/reload` must actually apply the new thresholds to the
2850	// live `AppState`, not just re-parse and echo the file. ──
2851	#[test]
2852	fn test_handle_ccr_reload_applies_thresholds_to_state() {
2853		let dir = std::env::temp_dir();
2854		let path = dir.join(format!(
2855			"aphrodite_reload_test_{}_{}.toml",
2856			std::process::id(),
2857			fnv1a_64(b"reload-test-salt")
2858		));
2859		std::fs::write(
2860			&path,
2861			r#"
2862[[proxies]]
2863name = "token"
2864mode = "token"
2865
2866[compression]
2867tool_threshold_token = 999
2868tool_threshold_cache = 1234
2869inline_threshold = 77
2870code_multiplier = 6.5
2871"#,
2872		)
2873		.unwrap();
2874
2875		// No other test in this crate reads/writes APHRODITE_CONFIG_PATH.
2876		std::env::set_var("APHRODITE_CONFIG_PATH", &path);
2877		let state = std::sync::Arc::new(test_state());
2878		let rt = tokio::runtime::Runtime::new().unwrap();
2879		let resp = rt.block_on(handle_ccr_reload(State(state.clone()))).into_response();
2880		std::env::remove_var("APHRODITE_CONFIG_PATH");
2881		let _ = std::fs::remove_file(&path);
2882
2883		assert_eq!(resp.status(), axum::http::StatusCode::OK);
2884		assert_eq!(state.token_compress_threshold.load(Ordering::Relaxed), 999);
2885		assert_eq!(state.cache_compress_threshold.load(Ordering::Relaxed), 1234);
2886		assert_eq!(state.inline_ccr_threshold.load(Ordering::Relaxed), 77);
2887		assert_eq!(state.code_multiplier_x100.load(Ordering::Relaxed), 650);
2888	}
2889
2890	#[test]
2891	fn test_stats_json_modes() {
2892		let cache = test_state();
2893		let stats = cache.stats_json();
2894		assert_eq!(stats["mode"], "cache");
2895		assert_eq!(stats["proxy"], "aphrodite");
2896
2897		let mut aph = test_state();
2898		aph.mode = ProxyMode::Token;
2899		let stats = aph.stats_json();
2900		assert_eq!(stats["mode"], "token");
2901	}
2902
2903	// ── T13 (F12): the enabled/disabled flag and the calls-stats object
2904	// must both be visible - a duplicate JSON key used to let the stats
2905	// object silently shadow the boolean. ──
2906	#[test]
2907	fn test_stats_json_tool_relay_enabled_flag_not_shadowed() {
2908		let mut state = test_state();
2909		state.tool_relay = true;
2910		let stats = state.stats_json();
2911		assert_eq!(stats["tool_relay_enabled"], true);
2912		assert!(
2913			stats["tool_relay"].is_object(),
2914			"the calls-stats object must still be present under its own key"
2915		);
2916		assert!(stats["tool_relay"]["total"].is_u64());
2917	}
2918
2919	#[test]
2920	fn test_ccr_create_response_serde_shape() {
2921		// Pins the wire contract of POST /ccr/create: field names and values
2922		// as they actually serialize, not just struct-literal field access.
2923		let resp = CcrCreateResponse {
2924			hash: "abc123".into(),
2925			token_savings_ratio: 2.5,
2926			original_size: 100,
2927			compressed_size: 40,
2928			marker_size: 40,
2929		};
2930		let v = serde_json::to_value(&resp).unwrap();
2931		assert_eq!(v["hash"], "abc123");
2932		assert_eq!(v["original_size"], 100);
2933		assert_eq!(v["compressed_size"], 40);
2934		assert_eq!(v["marker_size"], 40);
2935		assert!((v["token_savings_ratio"].as_f64().unwrap() - 2.5).abs() < 0.01);
2936		// Regression guard for bench_01/02 (F3): the field is
2937		// `token_savings_ratio`, never `compression_ratio`.
2938		assert!(v.get("compression_ratio").is_none());
2939	}
2940
2941	#[test]
2942	fn test_tool_relay_response_sync_serde_shape() {
2943		let resp = ToolRelayResponse {
2944			success: true,
2945			result: Some(serde_json::json!({"found": true})),
2946			error: None,
2947			async_call: false,
2948		};
2949		let v = serde_json::to_value(&resp).unwrap();
2950		assert_eq!(v["success"], true);
2951		assert_eq!(v["async_call"], false);
2952		assert_eq!(v["result"]["found"], true);
2953		assert!(v["error"].is_null());
2954	}
2955
2956	#[test]
2957	fn test_tool_relay_response_async_serde_shape() {
2958		let resp = ToolRelayResponse { success: true, result: None, error: None, async_call: true };
2959		let v = serde_json::to_value(&resp).unwrap();
2960		assert_eq!(v["async_call"], true);
2961		assert!(v["result"].is_null());
2962	}
2963
2964	// ── T3: detect_content_type ─────────────────────────────────
2965	#[test]
2966	fn test_detect_content_type_json_tool_output() {
2967		assert_eq!(proxy_detect_content_type(r#"{"exit_code": 0, "output": "ok"}"#), "tool_output");
2968	}
2969
2970	#[test]
2971	fn test_detect_content_type_invalid_json_is_text() {
2972		// Starts with '{' but isn't valid JSON - must not be misclassified.
2973		assert_eq!(proxy_detect_content_type("{ not json at all"), "text");
2974	}
2975
2976	#[test]
2977	fn test_detect_content_type_json_array() {
2978		assert_eq!(proxy_detect_content_type(r#"[{"a":1},{"a":2}]"#), "json");
2979	}
2980
2981	#[test]
2982	fn test_detect_content_type_rust_code() {
2983		let src = "use std::fmt;\nfn add(a:i32, b:i32) -> i32 {\n    a + b\n}\n";
2984		assert_eq!(proxy_detect_content_type(src), "code_rust");
2985	}
2986
2987	#[test]
2988	fn test_detect_content_type_python_code() {
2989		let src = "import os\nclass Foo:\n    def bar(self):\n        pass\n";
2990		assert_eq!(proxy_detect_content_type(src), "code_python");
2991	}
2992
2993	#[test]
2994	fn test_detect_content_type_go_code() {
2995		let src = "package main\nimport (\n\t\"fmt\"\n)\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n";
2996		assert_eq!(proxy_detect_content_type(src), "code_go");
2997	}
2998
2999	#[test]
3000	fn test_detect_content_type_js_code() {
3001		let src = "import { foo } from 'bar';\nexport const add = (a, b) => a + b;\nconst x = 1;\nconst y = 2;\n";
3002		assert_eq!(proxy_detect_content_type(src), "code_js");
3003	}
3004
3005	#[test]
3006	fn test_detect_content_type_error_first_line() {
3007		assert_eq!(
3008			proxy_detect_content_type("Traceback (most recent call last):\n  File \"x.py\", line 1\nValueError: bad\n"),
3009			"error"
3010		);
3011	}
3012
3013	#[test]
3014	fn test_detect_content_type_diff() {
3015		let d = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,3 +1,4 @@\n+added a \
3016		         line\n";
3017		assert_eq!(proxy_detect_content_type(d), "diff");
3018	}
3019
3020	#[test]
3021	fn test_detect_content_type_log_lines() {
3022		// Must not start with '{'/'[' (that short-circuits to the JSON branch).
3023		let log = "starting up\n[INFO] service ready\n[WARN] disk low\n[ERROR] connection lost\n";
3024		assert_eq!(proxy_detect_content_type(log), "log");
3025	}
3026
3027	#[test]
3028	fn test_detect_content_type_empty_is_text() {
3029		assert_eq!(proxy_detect_content_type(""), "text");
3030	}
3031
3032	#[test]
3033	fn test_detect_content_type_plain_text() {
3034		assert_eq!(proxy_detect_content_type("just some plain text\nnothing special\n"), "text");
3035	}
3036
3037	// ── T3: generate_metadata ───────────────────────────────────
3038	#[test]
3039	fn test_generate_metadata_rust_has_lang_and_fns() {
3040		let src = "fn add(a:i32, b:i32) -> i32 {\n    a + b\n}\n";
3041		let meta = generate_metadata(src, "code_rust");
3042		assert!(meta.contains("lang=rs"));
3043		assert!(meta.contains("fns=add"));
3044	}
3045
3046	#[test]
3047	fn test_generate_metadata_escapes_pipes() {
3048		// The 'On branch' line can legally contain a literal '|' - must be escaped to
3049		// '/'.
3050		let src = "On branch feature|weird\n";
3051		let meta = generate_metadata(src, "git");
3052		assert!(!meta.contains('|'), "metadata must not contain a raw pipe: {meta}");
3053	}
3054
3055	#[test]
3056	fn test_generate_metadata_max_400_chars() {
3057		let src = (0..100).map(|i| format!("fn f{i}() {{}}")).collect::<Vec<_>>().join("\n");
3058		let meta = generate_metadata(&src, "code_rust");
3059		assert!(meta.chars().count() <= 400, "metadata too long: {} chars", meta.chars().count());
3060	}
3061
3062	#[test]
3063	fn test_generate_metadata_error_branch_no_panic_on_multibyte_utf8() {
3064		// Multi-byte UTF-8 characters sit right around the ".rs:" match position -
3065		// byte-index slicing here must not panic on a non-char-boundary.
3066		let src = "日本語エラー at src/日本.rs:10:5 something\n";
3067		let meta = generate_metadata(src, "error");
3068		// No assertion beyond "did not panic" is required, but sanity-check shape.
3069		assert!(meta.is_empty() || meta.contains("trace=") || meta.contains("msg="));
3070	}
3071
3072	#[test]
3073	fn test_generate_metadata_text_has_line_count() {
3074		let meta = generate_metadata("a\nb\nc\n", "text");
3075		assert_eq!(meta, "ln=3");
3076	}
3077
3078	// ── T3: build_preview ────────────────────────────────────────
3079	#[test]
3080	fn test_build_preview_code_has_ct_prefix() {
3081		let src = "fn add(a:i32, b:i32) -> i32 {\n    a + b\n}\n";
3082		let preview = proxy_build_preview(src, "code_rust");
3083		assert!(preview.starts_with("[code_rust:"));
3084	}
3085
3086	// ── Parity (report 09 §5): for the common semantic shapes the proxy path
3087	// (`proxy_build_preview`) must emit the IDENTICAL preview string the Hermes
3088	// hook/FFI path emits (`crate::preview::build_preview`), so the two code
3089	// paths never drift. Both are wired to the same shared builder + detector.
3090	#[test]
3091	fn test_proxy_and_hook_previews_are_identical_for_semantic_shapes() {
3092		let git_status = " M src/preview.rs\nA  src/new.rs\nD  src/old.rs\n?? tmp/x\n?? tmp/y";
3093		let cargo_test = "test result: ok. 220 passed; 0 failed; 1 ignored; finished in 0.31s";
3094		let ripgrep = "src/a.rs:12:hit one\nsrc/a.rs:20:hit two\nsrc/b.rs:5:hit three";
3095		let ls = "-rw-r--r-- 1 u g 10 x a.rs\n-rw-r--r-- 1 u g 10 x b.rs\ndrwxr-xr-x 2 u g 64 x sub";
3096		for content in [git_status, cargo_test, ripgrep, ls] {
3097			// Both paths independently classify then build - the results must match.
3098			let ct = proxy_detect_content_type(content);
3099			let proxy_preview = proxy_build_preview(content, ct);
3100			let hook_preview = crate::preview::build_preview(ct, content);
3101			assert_eq!(proxy_preview, hook_preview, "preview drift for ct={ct} content={content:?}");
3102			assert!(proxy_preview.starts_with('['), "expected enriched preview, got {proxy_preview}");
3103		}
3104	}
3105
3106	#[test]
3107	fn test_build_preview_error_has_ct_prefix_via_error_line() {
3108		let src = "some noise\nerror[E0308]: mismatched types\nmore noise\n";
3109		let preview = proxy_build_preview(src, "error");
3110		assert!(preview.contains("error[E0308]"));
3111	}
3112
3113	#[test]
3114	fn test_build_preview_diff_has_ct_prefix() {
3115		let src = "diff --git a/x b/x\n--- a/x\n+++ a/x\n";
3116		let preview = proxy_build_preview(src, "diff");
3117		assert!(preview.starts_with("diff --git"));
3118	}
3119
3120	#[test]
3121	fn test_build_preview_json_has_ct_prefix() {
3122		let src = "{\"a\":1,\"b\":2}\n";
3123		let preview = proxy_build_preview(src, "json");
3124		assert!(preview.contains("keys"));
3125	}
3126
3127	// ── T3: cache_key_from_body / fnv1a_64 ────────────────────────
3128	#[test]
3129	fn test_cache_key_from_body_deterministic() {
3130		let body = br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}"#;
3131		let k1 = cache_key_from_body(body, "key-a");
3132		let k2 = cache_key_from_body(body, "key-a");
3133		assert!(k1.is_some());
3134		assert_eq!(k1, k2);
3135	}
3136
3137	#[test]
3138	fn test_cache_key_from_body_differs_by_api_key() {
3139		let body = br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}"#;
3140		let k1 = cache_key_from_body(body, "key-a");
3141		let k2 = cache_key_from_body(body, "key-b");
3142		assert_ne!(k1, k2);
3143	}
3144
3145	#[test]
3146	fn test_cache_key_from_body_none_on_junk() {
3147		assert_eq!(cache_key_from_body(b"not json", "key"), None);
3148		assert_eq!(cache_key_from_body(b"{}", "key"), None); // missing model/messages
3149	}
3150
3151	// ── T8 (F3): the cache key must cover the request parameters that
3152	// change what a valid response can look like, and streamed requests
3153	// must never be cached at all. ──
3154	#[test]
3155	fn test_cache_key_from_body_differs_by_tools_and_temperature_and_stream() {
3156		let base = br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}"#;
3157		let with_tools =
3158			br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function"}]}"#;
3159		let with_temp = br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"temperature":0.7}"#;
3160		let k_base = cache_key_from_body(base, "key");
3161		let k_tools = cache_key_from_body(with_tools, "key");
3162		let k_temp = cache_key_from_body(with_temp, "key");
3163		assert!(k_base.is_some());
3164		assert_ne!(k_base, k_tools, "differing `tools` must produce a different cache key");
3165		assert_ne!(k_base, k_temp, "differing `temperature` must produce a different cache key");
3166		assert_ne!(k_tools, k_temp);
3167	}
3168
3169	#[test]
3170	fn test_cache_key_from_body_none_when_streaming() {
3171		let streamed = br#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}"#;
3172		assert_eq!(cache_key_from_body(streamed, "key"), None);
3173	}
3174
3175	// ── T1 remainder (F5, report 06): response_cache entries must expire
3176	// on their own instead of being replayed forever. ──
3177	#[test]
3178	fn test_response_cache_get_expires_past_ttl() {
3179		let mut state = test_state();
3180		state.response_cache_ttl = std::time::Duration::from_millis(1);
3181		state
3182			.response_cache
3183			.lock()
3184			.unwrap()
3185			.put(42, (std::time::Instant::now(), b"cached".to_vec()));
3186		std::thread::sleep(std::time::Duration::from_millis(20));
3187		assert_eq!(response_cache_get(&state, 42), None, "expired entry must not be returned");
3188		assert!(
3189			state.response_cache.lock().unwrap().peek(&42).is_none(),
3190			"expired entry must be evicted, not just skipped"
3191		);
3192	}
3193
3194	#[test]
3195	fn test_response_cache_get_hits_within_ttl() {
3196		let mut state = test_state();
3197		state.response_cache_ttl = std::time::Duration::from_secs(3600);
3198		state
3199			.response_cache
3200			.lock()
3201			.unwrap()
3202			.put(7, (std::time::Instant::now(), b"cached".to_vec()));
3203		assert_eq!(response_cache_get(&state, 7), Some(b"cached".to_vec()));
3204	}
3205
3206	#[test]
3207	fn test_fnv1a_64_known_vectors() {
3208		// FNV-1a 64-bit offset basis is the hash of the empty input.
3209		assert_eq!(fnv1a_64(b""), 14695981039346656037);
3210		// Two different inputs should (overwhelmingly likely) hash differently.
3211		assert_ne!(fnv1a_64(b"a"), fnv1a_64(b"b"));
3212	}
3213
3214	// ── 02-F2: `body_wants_stream` decides which client (bounded vs
3215	// no-total-timeout) a request goes out on - must match on the exact
3216	// field real clients send, and fail closed (bounded client) on anything
3217	// unparseable rather than accidentally going timeout-free for garbage
3218	// input. ──
3219	#[test]
3220	fn test_body_wants_stream_true_when_set() {
3221		assert!(body_wants_stream(br#"{"model":"gpt-4o","stream":true}"#));
3222	}
3223
3224	#[test]
3225	fn test_body_wants_stream_false_when_absent_or_false() {
3226		assert!(!body_wants_stream(br#"{"model":"gpt-4o"}"#));
3227		assert!(!body_wants_stream(br#"{"model":"gpt-4o","stream":false}"#));
3228	}
3229
3230	#[test]
3231	fn test_body_wants_stream_false_on_invalid_json() {
3232		assert!(!body_wants_stream(b"not json"));
3233	}
3234
3235	fn test_state() -> AppState {
3236		use std::{collections::HashMap, sync::Mutex};
3237		AppState {
3238			client: HttpClient::new(),
3239			stream_client: HttpClient::new(),
3240			api_url: "https://upstream-openai.com".into(),
3241			model: "default-model".into(),
3242			api_key: "test".into(),
3243			ccr: None,
3244			add_markers: false,
3245			mode: ProxyMode::Cache,
3246			tool_relay: false,
3247			notify_url: None,
3248			notify_key: None,
3249			dev: false,
3250			requests_total: AtomicU64::new(0),
3251			requests_compressed: AtomicU64::new(0),
3252			tokens_saved: AtomicU64::new(0),
3253			ccr_hits: AtomicU64::new(0),
3254			ccr_misses: AtomicU64::new(0),
3255			ccr_created: AtomicU64::new(0),
3256			tool_relay_calls: AtomicU64::new(0),
3257			compression_ratio_ema: AtomicU64::new(200), // initial: 2.0x - conservative, avoids startup scale-up
3258			request_history: Mutex::new(VecDeque::new()),
3259			inline_ccr: Mutex::new(lru::LruCache::new(NonZeroUsize::new(1024).unwrap())),
3260			latency_buckets: [
3261				AtomicU64::new(0),
3262				AtomicU64::new(0),
3263				AtomicU64::new(0),
3264				AtomicU64::new(0),
3265				AtomicU64::new(0),
3266			],
3267			total_latency_micros: AtomicU64::new(0),
3268			last_errors: Mutex::new(VecDeque::new()),
3269			compressions_by_type: Mutex::new(HashMap::new()),
3270			response_cache: Mutex::new(lru::LruCache::new(NonZeroUsize::new(128).unwrap())),
3271			response_cache_ttl: std::time::Duration::from_secs(3600),
3272			cache_hits: AtomicU64::new(0),
3273			cache_misses: AtomicU64::new(0),
3274			fill_pct: AtomicU64::new(9000),
3275			task_tracker: TaskTracker::new(),
3276			inline_ccr_hits: AtomicU64::new(0),
3277			inline_ccr_misses: AtomicU64::new(0),
3278			tool_relay_success: AtomicU64::new(0),
3279			tool_relay_failure: AtomicU64::new(0),
3280			notify_success: AtomicU64::new(0),
3281			notify_failure: AtomicU64::new(0),
3282			upstream_errors_4xx: AtomicU64::new(0),
3283			upstream_errors_5xx: AtomicU64::new(0),
3284			upstream_timeouts: AtomicU64::new(0),
3285			upstream_connect_errors: AtomicU64::new(0),
3286			sse_stream_errors: AtomicU64::new(0),
3287			ccr_store_entries: AtomicU64::new(0),
3288			ccr_store_bytes: AtomicU64::new(0),
3289			request_body_bytes: AtomicU64::new(0),
3290			response_body_bytes: AtomicU64::new(0),
3291			upstream_latency_micros: AtomicU64::new(0),
3292			upstream_health_cache: std::sync::Mutex::new(None),
3293			cache_compress_threshold: AtomicUsize::new(CACHE_COMPRESS_THRESHOLD),
3294			token_compress_threshold: AtomicUsize::new(TOKEN_COMPRESS_THRESHOLD),
3295			inline_ccr_threshold: AtomicUsize::new(INLINE_CCR_THRESHOLD),
3296			code_multiplier_x100: AtomicU64::new(300),
3297		}
3298	}
3299
3300	/// `test_state()` with a real (in-memory) CCR backend attached, since
3301	/// `compress_chat_completion` only compresses when `state.ccr` is `Some`.
3302	///
3303	/// `pub(crate)` (rather than private) so other in-crate test modules -
3304	/// e.g. `retrieve::tests` - can build a real `AppState` without
3305	/// duplicating this ~40-field literal (report 05 T5 verification).
3306	pub(crate) fn test_state_with_ccr() -> AppState {
3307		AppState {
3308			ccr: Some(std::sync::Arc::new(InMemoryCcrStore::with_capacity_and_ttl(
3309				1000,
3310				std::time::Duration::from_secs(300),
3311			))),
3312			mode: ProxyMode::Token,
3313			..test_state()
3314		}
3315	}
3316
3317	/// A `CcrStore` whose `put` always fails - used to test report 02's F4
3318	/// fix: a failed store write must never be followed by replacing
3319	/// content with an unresolvable marker.
3320	struct FailingCcrStore;
3321	impl headroom_core::ccr::CcrStore for FailingCcrStore {
3322		fn put(&self, _hash: &str, _payload: &str) -> bool {
3323			false
3324		}
3325		fn get(&self, _hash: &str) -> Option<String> {
3326			None
3327		}
3328		fn len(&self) -> usize {
3329			0
3330		}
3331		fn del(&self, _hash: &str) -> bool {
3332			false
3333		}
3334	}
3335
3336	fn test_state_with_failing_ccr() -> AppState {
3337		AppState {
3338			ccr: Some(std::sync::Arc::new(FailingCcrStore)),
3339			mode: ProxyMode::Token,
3340			..test_state()
3341		}
3342	}
3343
3344	// ── T4 (F4): a failed ccr_put must leave content uncompressed, not
3345	// silently swapped for an unresolvable marker. ──
3346	#[test]
3347	fn test_compress_chat_completion_ccr_put_failure_leaves_content_uncompressed() {
3348		let state = test_state_with_failing_ccr();
3349		let content = "fn answer() -> i32 { 42 }\n".repeat(200); // well above threshold
3350		let body = chat_completion_body(&content);
3351		let rt = tokio::runtime::Runtime::new().unwrap();
3352		let result = rt.block_on(compress_chat_completion(&state, &body, None));
3353		// Nothing could be safely compressed (the only candidate's store
3354		// write failed), so the whole response must come back `None`
3355		// (unmodified pass-through), not `Some` with a poisoned marker.
3356		assert!(result.is_none(), "a failed ccr_put must not produce a compressed response");
3357	}
3358
3359	// ── T5 (F14): /ccr/create must report unavailability, not a
3360	// fabricated success, when CCR isn't enabled or the store write fails. ──
3361	#[test]
3362	fn test_handle_ccr_create_503_when_ccr_disabled() {
3363		let mut state = test_state();
3364		state.ccr = None;
3365		let state = std::sync::Arc::new(state);
3366		let rt = tokio::runtime::Runtime::new().unwrap();
3367		let resp = rt
3368			.block_on(handle_ccr_create(
3369				State(state),
3370				axum::http::HeaderMap::new(),
3371				Bytes::from_static(b"hello world"),
3372			))
3373			.into_response();
3374		assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
3375	}
3376
3377	#[test]
3378	fn test_handle_ccr_create_500_when_put_fails() {
3379		let state = std::sync::Arc::new(test_state_with_failing_ccr());
3380		let rt = tokio::runtime::Runtime::new().unwrap();
3381		let resp = rt
3382			.block_on(handle_ccr_create(
3383				State(state),
3384				axum::http::HeaderMap::new(),
3385				Bytes::from_static(b"hello world"),
3386			))
3387			.into_response();
3388		assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
3389	}
3390
3391	fn chat_completion_body(content: &str) -> Vec<u8> {
3392		serde_json::json!({
3393			"choices": [{
3394				"message": {"role": "assistant", "content": content}
3395			}]
3396		})
3397		.to_string()
3398		.into_bytes()
3399	}
3400
3401	// ── T4: compress_chat_completion ────────────────────────────
3402	#[test]
3403	fn test_compress_chat_completion_above_threshold_produces_marker() {
3404		// Token mode base threshold is 1024B; a large repeated text block
3405		// stays well above any auto-tuned multiplier of it.
3406		let content = "the quick brown fox jumps over the lazy dog. ".repeat(200);
3407		let body = chat_completion_body(&content);
3408		let state = test_state_with_ccr();
3409
3410		let result = tokio::runtime::Runtime::new()
3411			.unwrap()
3412			.block_on(compress_chat_completion(&state, &body, None));
3413
3414		let response = result.expect("content above threshold must be compressed");
3415		let new_content = response["choices"][0]["message"]["content"].as_str().unwrap();
3416		assert!(new_content.contains("<<<CCR:"), "expected a CCR marker, got: {new_content}");
3417
3418		// The original content must be retrievable from the CCR store.
3419		let ccr = state.ccr.as_ref().unwrap().clone();
3420		let hash = compute_key(content.as_bytes());
3421		let stored = tokio::runtime::Runtime::new().unwrap().block_on(ccr_get(&ccr, &hash));
3422		assert_eq!(stored.as_deref(), Some(content.as_str()));
3423	}
3424
3425	#[test]
3426	fn test_compress_chat_completion_below_threshold_is_none() {
3427		let content = "short reply";
3428		let body = chat_completion_body(content);
3429		let state = test_state_with_ccr();
3430
3431		let result = tokio::runtime::Runtime::new()
3432			.unwrap()
3433			.block_on(compress_chat_completion(&state, &body, None));
3434
3435		assert!(result.is_none(), "short content must not be compressed: {result:?}");
3436	}
3437
3438	#[test]
3439	fn test_compress_chat_completion_tool_call_arguments_pass_through_untouched() {
3440		// 02-F3: `tool_calls[].function.arguments` is JSON the client feeds
3441		// straight into its own tool executor, not model-facing prose - it
3442		// must never be replaced by an unresolvable-to-the-client CCR
3443		// marker, no matter how large. `message.content` (real model-facing
3444		// text) is the legitimate compression target and is large here too,
3445		// to confirm compression still happens for it while leaving
3446		// `arguments` completely alone.
3447		let big_args = serde_json::json!({"data": "x".repeat(4000)}).to_string();
3448		let big_content = "line of moderate length text content here.\n".repeat(200);
3449		let body = serde_json::json!({
3450			"choices": [{
3451				"message": {
3452					"role": "assistant",
3453					"content": big_content,
3454					"tool_calls": [{
3455						"function": {"name": "f", "arguments": big_args}
3456					}]
3457				}
3458			}]
3459		})
3460		.to_string()
3461		.into_bytes();
3462		let state = test_state_with_ccr();
3463
3464		let result = tokio::runtime::Runtime::new()
3465			.unwrap()
3466			.block_on(compress_chat_completion(&state, &body, None));
3467
3468		let response = result.expect("large message content must still be compressed");
3469		let new_content = response["choices"][0]["message"]["content"].as_str().unwrap();
3470		assert!(
3471			new_content.contains("<<<CCR:"),
3472			"expected message content to be compressed: {new_content}"
3473		);
3474		let new_args = response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
3475			.as_str()
3476			.unwrap();
3477		assert_eq!(
3478			new_args, big_args,
3479			"tool-call arguments must pass through untouched, never marker-replaced"
3480		);
3481	}
3482
3483	#[test]
3484	fn test_compress_chat_completion_budget_header_lowers_effective_threshold() {
3485		// A large-but-moderate payload: compressed when the budget header
3486		// requests aggressive compression (fill=0 -> 0.5x multiplier), but
3487		// left alone with no budget header (multiplier 1.0x) if it sits
3488		// between the two thresholds.
3489		let content = "line of moderate length text content here.\n".repeat(40); // ~1760B
3490		let body = chat_completion_body(&content);
3491
3492		let state_no_budget = test_state_with_ccr();
3493		let result_no_budget =
3494			tokio::runtime::Runtime::new()
3495				.unwrap()
3496				.block_on(compress_chat_completion(&state_no_budget, &body, None));
3497
3498		let state_low_budget = test_state_with_ccr();
3499		let result_low_budget = tokio::runtime::Runtime::new().unwrap().block_on(compress_chat_completion(
3500			&state_low_budget,
3501			&body,
3502			Some("0"),
3503		));
3504
3505		// A lower budget must never compress *less* than no budget at all.
3506		if result_no_budget.is_some() {
3507			assert!(
3508				result_low_budget.is_some(),
3509				"lower budget must compress at least as much as no budget"
3510			);
3511		}
3512	}
3513
3514	// ── T5: execute_tool_relay ───────────────────────────────────
3515	#[test]
3516	fn test_execute_tool_relay_retrieve_missing_hash_param() {
3517		let state = test_state_with_ccr();
3518		let result = tokio::runtime::Runtime::new().unwrap().block_on(execute_tool_relay(
3519			&state,
3520			"aphrodite_retrieve",
3521			&serde_json::json!({}),
3522		));
3523		assert_eq!(result, Err("missing hash".to_string()));
3524	}
3525
3526	#[test]
3527	fn test_execute_tool_relay_unknown_tool_is_err() {
3528		let state = test_state_with_ccr();
3529		let result = tokio::runtime::Runtime::new().unwrap().block_on(execute_tool_relay(
3530			&state,
3531			"not_a_real_tool",
3532			&serde_json::json!({}),
3533		));
3534		assert!(result.is_err());
3535	}
3536
3537	#[test]
3538	fn test_execute_tool_relay_compress_small_content_stores_inline_and_returns_marker() {
3539		let state = test_state_with_ccr();
3540		let content = "tiny"; // well under INLINE_CCR_THRESHOLD (256B)
3541		let result = tokio::runtime::Runtime::new().unwrap().block_on(execute_tool_relay(
3542			&state,
3543			"aphrodite_compress",
3544			&serde_json::json!({"content": content}),
3545		));
3546		let v = result.expect("compress must succeed");
3547		assert!(v["compressed"].as_str().unwrap().contains("<<<CCR:"));
3548		assert_eq!(v["original_size"], content.len());
3549	}
3550
3551	// ── T7 (F5, report 06): tiny relay-compress content must also land in
3552	// the durable backend, not only the 1024-entry inline LRU - otherwise a
3553	// busy session evicts the "durable-looking" marker's only copy within
3554	// minutes. Verifies the durable store has the content directly,
3555	// independent of the inline cache. ──
3556	#[test]
3557	fn test_execute_tool_relay_compress_small_content_also_stores_durably() {
3558		let state = test_state_with_ccr();
3559		let content = "tiny"; // well under INLINE_CCR_THRESHOLD (256B)
3560		let rt = tokio::runtime::Runtime::new().unwrap();
3561		let result = rt.block_on(execute_tool_relay(
3562			&state,
3563			"aphrodite_compress",
3564			&serde_json::json!({"content": content}),
3565		));
3566		let v = result.expect("compress must succeed");
3567		let hash = v["hash"].as_str().unwrap().to_string();
3568
3569		// Simulate the inline entry having been evicted (busy session /
3570		// restart) by going straight to the durable backend.
3571		let ccr = state.ccr.as_ref().unwrap();
3572		let durable = rt.block_on(ccr_get(ccr, &hash));
3573		assert_eq!(
3574			durable.as_deref(),
3575			Some(content),
3576			"tiny content must also be durable, not inline-only"
3577		);
3578	}
3579
3580	#[test]
3581	fn test_execute_tool_relay_retrieve_finds_inline_entry() {
3582		let state = test_state_with_ccr();
3583		let content = "tiny";
3584		let compressed = tokio::runtime::Runtime::new()
3585			.unwrap()
3586			.block_on(execute_tool_relay(
3587				&state,
3588				"aphrodite_compress",
3589				&serde_json::json!({"content": content}),
3590			))
3591			.unwrap();
3592		let hash = compressed["hash"].as_str().unwrap().to_string();
3593
3594		let retrieved = tokio::runtime::Runtime::new()
3595			.unwrap()
3596			.block_on(execute_tool_relay(
3597				&state,
3598				"aphrodite_retrieve",
3599				&serde_json::json!({"hash": hash}),
3600			))
3601			.unwrap();
3602		assert_eq!(retrieved["found"], true);
3603		assert_eq!(retrieved["content"], content);
3604	}
3605
3606	// ── T5 (F3): execute_tool_relay's "aphrodite_retrieve" arm must
3607	// normalize the hash argument the same way `resolve_one` already does -
3608	// strip a `|type|size` marker-body suffix an LLM might echo back, and
3609	// trim surrounding whitespace.
3610	#[test]
3611	fn test_execute_tool_relay_retrieve_normalizes_pipe_suffixed_and_whitespace_hash() {
3612		let state = test_state_with_ccr();
3613		let content = "tiny";
3614		let rt = tokio::runtime::Runtime::new().unwrap();
3615		let compressed = rt
3616			.block_on(execute_tool_relay(
3617				&state,
3618				"aphrodite_compress",
3619				&serde_json::json!({"content": content}),
3620			))
3621			.unwrap();
3622		let hash = compressed["hash"].as_str().unwrap().to_string();
3623
3624		for hash_arg in [hash.clone(), format!("{hash}|tool|1024"), format!("  {hash}  ")] {
3625			let retrieved = rt
3626				.block_on(execute_tool_relay(
3627					&state,
3628					"aphrodite_retrieve",
3629					&serde_json::json!({"hash": hash_arg}),
3630				))
3631				.unwrap();
3632			assert_eq!(retrieved["found"], true, "hash arg {hash_arg:?} must resolve: {retrieved:?}");
3633			assert_eq!(retrieved["content"], content);
3634		}
3635	}
3636
3637	// ── T15 (F2): regression tests for the historical corpus examples ────
3638	// These exercise the real Rust code (unlike Maintain/examples/*.py,
3639	// which re-implement the buggy/fixed logic in Python and can never
3640	// catch a Rust regression - see Maintain/examples/README.md).
3641
3642	/// Corpus 07_tokens_saved.py: the AtomicU64 must actually be incremented
3643	/// on the real compression path, not just exist unused in /stats.
3644	#[test]
3645	fn regression_07_tokens_saved_increments_on_compress() {
3646		let content = "the quick brown fox jumps over the lazy dog. ".repeat(200);
3647		let body = chat_completion_body(&content);
3648		let state = test_state_with_ccr();
3649
3650		assert_eq!(state.tokens_saved.load(Ordering::Relaxed), 0);
3651		let result = tokio::runtime::Runtime::new()
3652			.unwrap()
3653			.block_on(compress_chat_completion(&state, &body, None));
3654		assert!(result.is_some(), "content above threshold must compress");
3655		assert!(
3656			state.tokens_saved.load(Ordering::Relaxed) > 0,
3657			"tokens_saved must be incremented by the real compression path"
3658		);
3659	}
3660
3661	/// Corpus 11_should_compress.py: threshold gating must actually skip
3662	/// compression below threshold, not compress unconditionally.
3663	#[test]
3664	fn regression_11_below_threshold_skips_compression_and_counter() {
3665		let content = "short reply below any threshold";
3666		let body = chat_completion_body(content);
3667		let state = test_state_with_ccr();
3668
3669		let result = tokio::runtime::Runtime::new()
3670			.unwrap()
3671			.block_on(compress_chat_completion(&state, &body, None));
3672		assert!(result.is_none(), "below-threshold content must not compress");
3673		assert_eq!(
3674			state.tokens_saved.load(Ordering::Relaxed),
3675			0,
3676			"no savings should be recorded when nothing was compressed"
3677		);
3678	}
3679
3680	/// Corpus 13_engine_truncation.py: a CCR marker must never be truncated
3681	/// mid-terminator by a preview-length budget - format_ccr_output's
3682	/// output must always contain a complete `<<<CCR:hash|type|size>>>` line,
3683	/// regardless of how long the preview or metadata are.
3684	#[test]
3685	fn regression_13_marker_terminator_never_truncated() {
3686		let hash = "abc123def456abc123def456abc123def456";
3687		let huge_preview = "x".repeat(10_000);
3688		let huge_metadata = "y".repeat(10_000);
3689		let out = proxy_format_ccr_output(&huge_preview, "text", &huge_metadata, None, hash, 123456);
3690		let expected_terminator = format!("<<<CCR:{hash}|text|123456>>>");
3691		assert!(
3692			out.contains(&expected_terminator),
3693			"marker terminator must always be complete and unsliced, regardless of preview/metadata length"
3694		);
3695	}
3696
3697	// ── T8: property tests ───────────────────────────────────────
3698	use proptest::{prop_assert, proptest};
3699
3700	proptest! {
3701		/// `detect_content_type` and `generate_metadata` must never panic on
3702		/// arbitrary UTF-8 input (this is literally the module's threat
3703		/// model - arbitrary tool output), and the generated metadata must
3704		/// respect its own documented invariants: no pipe/newline, <=400 chars.
3705		#[test]
3706		fn prop_classifier_and_metadata_never_panic(s in ".*") {
3707			let ct = proxy_detect_content_type(&s);
3708			let meta = generate_metadata(&s, ct);
3709			prop_assert!(!meta.contains('|'));
3710			prop_assert!(!meta.contains('\n'));
3711			prop_assert!(meta.chars().count() <= 400);
3712		}
3713	}
3714
3715	// ── 04-T8: SSE detection + handler-level streaming tests ──────
3716
3717	fn header_value(s: &str) -> axum::http::HeaderValue {
3718		axum::http::HeaderValue::from_str(s).unwrap()
3719	}
3720
3721	#[test]
3722	fn test_is_sse_exact_match() {
3723		assert!(is_sse(Some(&header_value("text/event-stream"))));
3724	}
3725
3726	#[test]
3727	fn test_is_sse_prefix_match_with_charset() {
3728		assert!(is_sse(Some(&header_value("text/event-stream; charset=utf-8"))));
3729	}
3730
3731	#[test]
3732	fn test_is_sse_rejects_non_sse_content_types() {
3733		assert!(!is_sse(Some(&header_value("application/json"))));
3734		assert!(!is_sse(Some(&header_value("text/plain"))));
3735		// Not a prefix match on a superstring that merely contains the token.
3736		assert!(!is_sse(Some(&header_value("text/x-event-stream"))));
3737	}
3738
3739	#[test]
3740	fn test_is_sse_missing_header_is_false() {
3741		assert!(!is_sse(None));
3742	}
3743
3744	/// A minimal raw-TCP mock upstream: accepts one connection, drains the
3745	/// request, and writes back a canned HTTP/1.1 response before closing -
3746	/// this crate has no HTTP mocking dependency, and a hand-rolled listener
3747	/// is simpler than standing up a second axum server for one test.
3748	async fn spawn_mock_upstream(response: &'static str) -> std::net::SocketAddr {
3749		let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3750		let addr = listener.local_addr().unwrap();
3751		tokio::spawn(async move {
3752			use tokio::io::{AsyncReadExt, AsyncWriteExt};
3753			let (mut socket, _) = listener.accept().await.unwrap();
3754			let mut buf = [0u8; 4096];
3755			// Drain (don't need to parse) the request up to the header terminator.
3756			loop {
3757				let n = socket.read(&mut buf).await.unwrap_or(0);
3758				if n == 0 || buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
3759					break;
3760				}
3761			}
3762			let _ = socket.write_all(response.as_bytes()).await;
3763			let _ = socket.shutdown().await;
3764		});
3765		addr
3766	}
3767
3768	fn test_request(
3769		state: Arc<AppState>,
3770		path: &str,
3771		body: &'static str,
3772	) -> (
3773		axum::extract::State<Arc<AppState>>,
3774		Method,
3775		axum::extract::OriginalUri,
3776		axum::http::HeaderMap,
3777		Bytes,
3778	) {
3779		(
3780			axum::extract::State(state),
3781			Method::POST,
3782			axum::extract::OriginalUri(format!("http://x{path}").parse().unwrap()),
3783			axum::http::HeaderMap::new(),
3784			Bytes::from_static(body.as_bytes()),
3785		)
3786	}
3787
3788	#[tokio::test]
3789	async fn test_sse_response_is_streamed_with_header_and_not_cached() {
3790		let addr = spawn_mock_upstream(
3791			"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nConnection: close\r\n\r\ndata: \
3792			 {\"delta\":\"hi\"}\n\ndata: [DONE]\n\n",
3793		)
3794		.await;
3795
3796		let mut state = test_state();
3797		state.api_url = format!("http://{addr}");
3798		let state = Arc::new(state);
3799
3800		// Non-streaming request body (no "stream": true) - proves the SSE
3801		// path is driven by the upstream's Content-Type, not the request.
3802		let (s, m, p, h, b) = test_request(state.clone(), CHAT_COMPLETIONS_PATH, r#"{"model":"x","messages":[]}"#);
3803		let response = proxy_handler(s, m, p, h, b).await.into_response();
3804
3805		assert_eq!(
3806			response.headers().get("X-Aphrodite-Streamed").map(|v| v.to_str().unwrap()),
3807			Some("true"),
3808			"SSE responses must be marked with X-Aphrodite-Streamed"
3809		);
3810		assert_eq!(
3811			response.headers().get("content-type").map(|v| v.to_str().unwrap()),
3812			Some("text/event-stream")
3813		);
3814
3815		// No cache write: an SSE response must never populate the response
3816		// cache (F3's whole point - a cached entry is a single buffered JSON
3817		// body, nothing like a stream).
3818		let cache_key = cache_key_from_body(r#"{"model":"x","messages":[]}"#.as_bytes(), state.api_key.expose());
3819		if let Some(ck) = cache_key {
3820			assert!(response_cache_get(&state, ck).is_none(), "SSE response must not be cached");
3821		}
3822	}
3823
3824	#[tokio::test]
3825	async fn test_non_sse_response_is_not_streamed() {
3826		let addr = spawn_mock_upstream(
3827			"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nConnection: close\r\n\r\n{\"ok\":true}",
3828		)
3829		.await;
3830
3831		let mut state = test_state();
3832		state.api_url = format!("http://{addr}");
3833		let (s, m, p, h, b) = test_request(Arc::new(state), "/v1/models", "");
3834		let response = proxy_handler(s, m, p, h, b).await.into_response();
3835
3836		assert!(
3837			response.headers().get("X-Aphrodite-Streamed").is_none(),
3838			"a plain JSON upstream response must not be marked as streamed"
3839		);
3840	}
3841}